Swift 5.5. Что нового?

Туториалы

Swift 5.5. Что нового?

Async/await, actors, throwing properties - это и многое другое ждет нас в новой версии Swift 5.5. Текущее обновление языка привнесло так много новшеств, что проще перечислить, чего там нет.

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

1. Впервые за все время развития языка, в этом обновлении большинство важных предложений Swift Evolution были очень тесно связаны между собой. Поэтому даже с учетом того, что все примеры приведены в определенной хронологии, некоторые из них станут понятны только после того, как вы изучите их в совокупности.
2. Некоторые представленные фичи до сих проходят стадию Swift Evolution, хотя в настоящее время они фактически доступны в последних бетах Swift 5.5. Это значит, что эти фичи могут дорабатываться даже после проведения WWDC21 и эта статья скорее всего будет доработана уже после конференции.

Swift Evolution - это публичный репозиторий языка Swift, где принимаются предложения по развитию и улучшению языка

Async/await

Предложение SE-0296 вводит в Swift асинхронные функции, которые позволяют запускать сложный асинхронный код почти так же, как если бы он был синхронным. Это происходит в два этапа:

- помечаем асинхронные функции новым ключевым словом async,
- затем вызываем их с помощью ключевого слова await, как и в других языках, таких как C# и JavaScript.

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

Представим, что нам нужно запросить у сервера 100_000 записей о погоде, чтобы затем обработать эти записи и рассчитать среднюю температуру за определенный период времени. После этого нам нужно будет отправить полученный результат обратно на сервер:

func fetchWeatherHistory(completion: @escaping ([Double]) -> Void) {
    // Здесь мы парсим json и на основе полученных данных отправляем обратно на сервер массив из 100000 элементов с типом Double в диапазоне от -10 до 30
    DispatchQueue.global().async {
        let results = (1...100_000).map { _ in Double.random(in: -10...30) }
        completion(results)
    }
}

func calculateAverageTemperature(for records: [Double], completion: @escaping (Double) -> Void) {
    // Здесь мы суммируем все элементы массива и делим полученный результат на количество его элементов, т.е. находим среднее арифметическое
    DispatchQueue.global().async {
        let total = records.reduce(0, +)
        let average = total / Double(records.count)
        completion(average)
    }
}

func upload(result: Double, completion: @escaping (String) -> Void) {
    // Здесь так же выполняем какой то сетевой код в соответсвии с полученным результатом и в конце отправляем "ОК"
    DispatchQueue.global().async {
        completion("OK")
    }
}

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

fetchWeatherHistory { records in
    calculateAverageTemperature(for: records) { average in
        upload(result: average) { response in
            print("Server response: \(response)")
        }
    }
}

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

Вообще здесь есть несколько проблем:

  • Блоки замыканий этих функций могут быть вызваны более одного раза, а могут и вовсе не вызываться.
  • Синтаксис параметра @escaping (String) -> Void сложен для восприятия (если не считать, что мы к нему привыкли).
  • Здесь у нас образовалась так называемая “пирамида погибели”, в которой каждая вложенность увеличивает отступы, придавая коду форму пирамиды. Чем больше становится таких вкладок, тем сложнее отследить логику.
  • А до версии Swift 5.0, в которой появился тип Result было еще и сложно обрабатывать ошибки.

Начиная с версии языка Swift 5.5, мы можем разгружать слишком сложную логику функций, помечая их как функции с асинхронно возвращаемыми значениями, без использования блоков замыканий:

func fetchWeatherHistory() async -> [Double] {
    (1...100_000).map { _ in Double.random(in: -10...30) }
}

func calculateAverageTemperature(for records: [Double]) async -> Double {
    let total = records.reduce(0, +)
    let average = total / Double(records.count)
    return average
}

func upload(result: Double) async -> String {
    "OK"
}

Здесь мы просто даем понять компилятору, что значения методов нужно возвращать асинхронно. И вот как будет выглядеть вызов таких методов:

func processWeather() async {
    let records = await fetchWeatherHistory()
    let average = await calculateAverageTemperature(for: records)
    let response = await upload(result: average)
    print("Server response: \(response)")
}

Как видите, код без кортежей выглядит гораздо чище и проще, так как мы избавились от “пирамиды погибели”. Такой код еще называют “прямолинейным” или “straight-line code”. Не считая ключевого слова await, наш код выглядит так же, как обычный синхронный код.

При работе с асинхронными функциями следует соблюдать несколько простых правил:

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

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

Async/await идеально сочетается с try/catch. Это значит, что при работе с асинхронными функциями и инициализаторами можно обрабатывать ошибки. Единственная оговорка здесь заключается в том, что Swift ожидает соблюдение определенного порядка для ключевых слов, и этот порядок меняется на обратный между вызовом функции и её реализацией.

Рассмотрим пример, в котором у нас есть функции для загрузки списка пользователей из сети и сохранения их на диск. Обе эти функции выкидывают throws, т.е. могут завершиться с ошибкой:

enum UserError: Error {
    case invalidCount, dataTooLong
}

func fetchUsers(count: Int) async throws -> [String] {
    if count > 3 {
        // Слишком много пользователей для загрузки
        throw UserError.invalidCount
    }

    // Здесь логика работы с сетью в результате которой мы возвращаем то количество пользователей, которое указано в параметре.
    return Array(["Antoni", "Karamo", "Tan"].prefix(count))
}

func save(users: [String]) async throws -> String {
    let savedUsers = users.joined(separator: ",")

    if savedUsers.count > 32 {
        throw UserError.dataTooLong
    } else {
        // Логика по сохранению пользователей
        return "Saved \(savedUsers)!"
    }
}

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

Когда же дело доходит до их вызова, порядок ключевых слов меняется и сначала мы пишем try, а затем уже await:

func updateUsers() async {
    do {
        let users = try await fetchUsers(count: 3)
        let result = try await save(users: users)
        print(result)
    } catch {
        print("Oops!")
    }
}

Итак, при определении функции мы сначала говорим, что функция должна быть асинхронной, а затем то, что она должна выкидывать throws. При вызове функции мы сначала пишем try, а затем await. Такой вариант читается более естественно, чем await try и кроме того лучше отображает, что происходит на самом деле: мы ждем завершения некоторой работы, а когда она все же завершится, она может закончиться неудачей.

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

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

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

Async/await: коллекции

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

Ну и конечно же, в этом случае нам также необходимо находится в асинхронном контексте.

Использование протокола AsyncSequence почти идентично использованию протокола Sequence, за исключением того, что типы данных должны соответствовать AsyncSequence и AsyncIterator, а метод next() должен быть помечен как async. По завершению последовательности метод next() должен вернуть nil, как и при работе с методом протокола Sequence.

Рассмотрим пример работы с последовательностью DoubleGenerator, которая при каждом вызове будет возвращать значение, увеличенное вдвое. Последовательность будет начинаться с единицы:

struct DoubleGenerator: AsyncSequence {
    typealias Element = Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 1

        mutating func next() async -> Int? {
            defer { current &*= 2 }

            if current < 0 { return nil } else { return current } } } func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator()
    }
}

Совет: если ключевое слово async удалить отовсюду, где оно встречается в этом коде, то у нас получится реализация протокола Sequence. Вот насколько два этих протокола схожи между собой. По сути их отличает друг от друга всего лишь одно ключевое слово.

При работе с асинхронной последовательностью, её нужно перебирать в асинхронном контексте, используя для этого for await:

func printAllDoubles() async {
    for await number in DoubleGenerator() {
        print(number)
    }
}

Протокол AsyncSequence также реализуют такие функциональные методы, как map(), compactMap(), allSatisfy() и другие. Например, мы могли бы проверить, выводит ли наш генератор определенное число следующим образом:

func containsExactNumber() async {
    let doubles = DoubleGenerator()
    let match = await doubles.contains(16_777_216)
    print(match)
}

Ну и конечно же в этом случае нам также необходимо находится в асинхронном контексте.

Эффективная работа с read-only свойствами

Предложение SE-0310 улучшило свойства, доступные только для чтения. Теперь они поддерживают ключевые слова async и throws, как по отдельности так и вместе, что делает их значительно более гибкими в использовании.

Чтобы продемонстрировать это на примере создадим структуру BundleFile, которая должна загружать содержимое файла в пакет ресурсов нашего приложения. При этом надо учитывать такие моменты, что файл может оказаться не читаемым, файл может быть слишком большим, что потребует много времени для чтения или его может вовсе не быть. Учитывая это, мы можем пометить свойство contents как async throws:

enum FileError: Error {
    case missing, unreadable
}

struct BundleFile {
    let filename: String

    var contents: String {
        get async throws {
            guard let url = Bundle.main.url(forResource: filename, withExtension: nil) else {
                throw FileError.missing
            }

            do {
                return try String(contentsOf: url)
            } catch {
                throw FileError.unreadable
            }
        }
    }
}

Так как свойство contents одновременно помечено и как async, и как throws, то мы можем использовать try await перед тем, как обратиться к нему:

func printHighScores() async throws {
    let file = BundleFile(filename: "highscores")
    try await print(file.contents)
}

Предложение SE-0304 представляет целый ряд подходов к выполнению, отмене и мониторингу параллельных операций в Swift и основывается на принципах async/await, асинхронных последовательностях, рассмотренных выше.

Для наглядности рассмотрим пару примеров. Первый пример с использованием асинхронной функции, позволяющей получать прогноз погоды для определенной локации. Во втором примере рассмотрим синхронную функцию, которая позволяет вычислять число находящееся в определенном диапазоне из последовательности Фибоначчи:

enum LocationError: Error {
    case unknown
}

func getWeatherReadings(for location: String) async throws -> [Double] {
    switch location {
    case "London":
        return (1...100).map { _ in Double.random(in: 6...26) }
    case "Rome":
        return (1...100).map { _ in Double.random(in: 10...32) }
    case "San Francisco":
        return (1...100).map { _ in Double.random(in: 12...20) }
    default:
        throw LocationError.unknown
    }
}

func fibonacci(of number: Int) -> Int {
    var first = 0
    var second = 1

    for _ in 0 ..< number {
        let previous = first
        first = second
        second = previous + first
    }

    return first
}

Самый простой асинхронный подход представляет из себя структурированный параллелизм (structured concurrency). При таком подходе используется атрибут @main для немедленного перехода в асинхронный контекст. Сам метод main() так же помечается, как асинхронный:

@main
struct Main {
    static func main() async throws {
        let readings = try await getWeatherReadings(for: "London")
        print("Readings are: \\(readings)")
    }
}

 

Заметка

До релиза это так же возможно сделать в main.swift без использования атрибута @main.

С концепцией структурированного параллелизма в языке появилось два новых типа данных: Task и TaskGroup, которые позволяют выполнять параллельные операции индивидуально или скоординированно.

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

Это значит, что мы можем вызывать метод fibonacci(of : ) в фоновом потоке столько раз, сколько нужно, чтобы вычислить первые 50 чисел последовательности:

func printFibonacciSequence() async {
    let taskOne = Task { () -> [Int] in
        var numbers: [Int] = []

        for iteration in 0..<50 {
            let result = fibonacci(of: iteration)
            numbers.append(result)
        }

        return numbers
    }

    let resultOne = await taskOne.value
    print("The first 50 numbers in the Fibonacci sequence are: \\(resultOne)")
}

Как видите, в блоке замыкания мы явно определили тип Task {() -> [Int]. Если код в теле Task довольно простой, то тип, возвращаемый в блоке замыкания можно опустить:

let taskOne = Task {
    (0..<50).map(fibonacci)
}

Опять же, задача запускается, как только она была создана, и функция printFibonacciSequence() продолжит работу в каком бы потоке она ни была, пока вычисляются числа Фибоначчи.

Заметка

Задачи представляют из себя несбегающие замыкания, потому что выполнение задачи происходит сразу же. Это означает, что если внутри класса или структуры вы используете свойство или метод с типом Task, то ключевое слово self использовать не нужно.

await taskOne.value гарантирует, что когда дело дойдет до чтения итоговых чисел, выполнение метода printFibonacciSequence() будет приостановлено до тех пор, пока вывод задачи не будет готов, после результат будет возвращен. Если возвращаемый результат не важен, и вы просто хотите, чтобы код запускался и останавливался в определенное время, то хранить задачу не нужно.

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

func runMultipleCalculations() async throws {
    let taskOne = Task {
        (0..<50).map(fibonacci)
    }

    let taskTwo = Task {
        try await getWeatherReadings(for: "Rome")
    }

    let resultOne = await taskOne.value
    let resultTwo = try await taskTwo.value
    print("The first 50 numbers in the Fibonacci sequence are: \\(resultOne)")
    print("Rome weather readings are: \\(resultTwo)")
}

Swift предоставляет встроенные приоритеты для задач: высокий, стандартный, низкий и фоновый. В приведенном выше примере приоритет не задан, поэтому он будет выставлен по умолчанию, но при необходимости его можно задать следующим образом: Task(priority: .high).

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

  • Вызов Task.sleep() переводит текущую задачу спящий режим на определенное количество наносекунд. 1_000_000_000 (миллиард) наносекунд равняется 1 секунде.
  • Вызов Task.checkCancellation() проверяет не был ли вызван для отмены метод cancel(), и если был, то выбросит CancellationError.
  • Вызов Task.yield() приостанавливает текущую задачу на несколько мгновений, чтобы дать некоторое время другим задачам, которые могут ожидать свой очереди. Это особенно важно, если вы выполняете высоконагруженную работу в цикле.

Давайте рассмотрим, как перевести выполнение задачи в спящий режим на одну секунду, а потом отменить её выполнение до того, как она завершится:

func cancelSleepingTask() async {
    let task = Task { () -> String in
        print("Starting")
        await Task.sleep(1_000_000_000)
        try Task.checkCancellation()
        return "Done"
    }

    // Задача была запущена, но мы завершаем её пока она находится в спящем режиме
    task.cancel()

    do {
        let result = try await task.value
        print("Result: \\(result)")
    } catch {
        print("Task was cancelled.")
    }
}

В этом примере Task.checkCancellation() поймет, что задача была отменена, и немедленно выбросит CancellationError, но мы не узнаем об этом, пока не попытаемся прочесть значение task.value.

Заметка

Можно использовать task.result, чтобы получить значение одного из двух кейсов перечисления Result: success в случае успеха и failure при неудаче. В приведенном выше коде мы вернем значение с типом Result<String, Error>, что в свою очередь не потребует вызова через try, т.к. нам в любом случае придется обрабатывать оба кейса.

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

Группы задач не имеют простого и общедоступного инициализатора для того, чтобы свести к минимуму риск их неправильного использования разработчиками. Вместо этого они создаются при помощи функции withTaskGroup(), которая возвращает в теле блока замыкания экземпляр группы задач. Имея этот экземпляр, можно создать список задач при помощи метода async().

Важно: вы не должны пытаться передавать группу задач за пределы блока замыкания метода withTaskGroup(). Компилятор вам этого сделать не запретит, но вы тем самым наживете себе проблемы.

Давай рассмотрим простой пример того, как работать с группами задач:

func printMessage() async {
    let string = await withTaskGroup(of: String.self) { group -> String in
        group.async { "Hello" }
        group.async { "From" }
        group.async { "A" }
        group.async { "Task" }
        group.async { "Group" }

        var collected: [String] = []

        for await value in group {
            collected.append(value)
        }

        return collected.joined(separator: " ")
    }

    print(string)
}

Тут мы создаем группу задач, предназначенную для создания одной строки. Мы выстраиваем в очередь несколько блоков замыканий, используя метод async() из экземпляра group. Каждое замыкание возвращает одну строку, которая затем собирается в массив строк, после чего объединяется в одну целую строку, которая в итоге выводится на консоль.

Заметка

Все задачи в группе должны возвращать один и тот же тип данных, поэтому для более сложного примера вам может потребоваться создать перечисление со связанными значениями (ассоциированными параметрами), чтобы получить именно то, что вы хотите. Более простая альтернатива представлена в отдельном предложении Async Let Bindings.

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

Если группа задач выполняет код, который может вызвать ошибку, то её можно обработать непосредственно внутри группы, либо позволить ей "всплыть" за её пределами. Последний вариант обрабатывается с помощью функции withThrowingTaskGroup(), которую нужно вызывать с помощью try.

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

func printAllWeatherReadings() async {
    do {
        print("Calculating average weather…")

        let result = try await withThrowingTaskGroup(of: [Double].self) { group -> String in
            group.async {
                try await getWeatherReadings(for: "London")
            }

            group.async {
                try await getWeatherReadings(for: "Rome")
            }

            group.async {
                try await getWeatherReadings(for: "San Francisco")
            }

            // Конвертация массива массивов в единый массив с типом Double
            let allValues = try await group.reduce([], +)

            // Расчет средней арифметической от суммы элементов массива
            let average = allValues.reduce(0, +) / Double(allValues.count)
            return "Overall average temperature is \\(average)"
        }

        print("Done! \\(result)")
    } catch {
        print("Error calculating data.")
    }
}

В данном случае каждый из вызовов async() идентичен, за исключением локации, которую мы передаем в виде строки в параметр. Поэтому вызов async() можно вызывать в цикле for-in: for location in ["London", "Rome", "San Francisco"] {.

Группы задач содержат метод cancelAll(), который отменяет все задачи группы, но вызов async() впоследствии продолжит добавлять задачи в группу. В качестве альтернативы можно использовать asyncUnlessCancelled(), чтобы не добавлять задачи после их завершения. Метод возвращает логическое свойство, которое определяет была ли задача добавлена.

async let bindings

Реализация предложения SE-0317 дает возможность создавать и ожидать дочерние задачи с использованием простого синтаксиса async let. Эту концепцию удобно использовать в качестве альтернативы группам задач, результат которых должен возвращать разные типы данных.

В качестве примера создадим структуру с тремя свойствами разного типа и три асинхронные функции, которые возвращают результаты того же типа, что имеют свойства структуры:

struct UserData {
    let username: String
    let friends: [String]
    let highScores: [Int]
}

func getUser() async -> String {
    "Taylor Swift"
}

func getHighScores() async -> [Int] {
    [42, 23, 16, 15, 8, 4]
}

func getFriends() async -> [String] {
    ["Eric", "Maeve", "Otis"]
}

Результат работы этих функций можно присвоить свойствам инициализированным при помощи async let. Это позволит вызывать все функции одновременно, но при этом дождаться результата работы каждой из них. После этого мы можем использовать эти свойства для инициализации экземпляра модели User:

func printUserDetails() async {
    async let username = getUser()
    async let scores = getHighScores()
    async let friends = getFriends()

    let user = await UserData(name: username, friends: friends, highScores: scores)
    print("Hello, my name is \\(user.name), and I have \\(user.friends.count) friends!")
}

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

При работе с функциями выкидывающими throws использовать ключевое слово try вместе с async let не нужно. Оно будет определено автоматически в том месте, где вы ожидаете получить результат. Тоже самое относится и к ключевому слову await. Вместо того, чтобы писать try await someFunction() метод с использованием async let можно вызывать без каких либо ключевых слов: someFunction().

Чтобы продемонстрировать это, создадим асинхронную функцию для рекурсивного вычисления чисел в последовательности Фибоначчи. Этот подход довольно примитивен и не оптимален. Он очень ресурсоемкий, поэтому, чтобы не повесить компилятор, ограничим диапазон выборки от 0 до 22:

enum NumberError: Error {
    case outOfRange
}

func fibonacci(of number: Int) async throws -> Int {
    if number < 0 || number > 22 {
        throw NumberError.outOfRange
    }

    if number < 2 { return number }
    async let first = fibonacci(of: number - 2)
    async let second = fibonacci(of: number - 1)
    return try await first + second
}

В этом коде рекурсивные вызовы метода fibonacci(of : ) неявно вызываются, как try await fibonacci(of : ), но мы можем оставить их и обработать непосредственно в следующей строке.

Антракт

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

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

Continuations для взаимодействия асинхронных задач с синхронным кодом

Предложение SE-0300 представляет новый функционал, который помогает адаптировать старые API в стиле completion handler в современный асинхронный код.

В примере ниже, функция возвращает результат работы асинхронно в блоке замыкания:

func fetchLatestNews(completion: @escaping ([String]) -> Void) {
    DispatchQueue.main.async {
        completion(["Swift 5.5 release", "Apple acquires Apollo"])
    }
}

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

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

Давайте обернем вызов функции fetchLatestNews в новую асинхронную функцию, используя для этого continuations:

func fetchLatestNews() async -> [String] {
    await withCheckedContinuation { continuation in
        fetchLatestNews { items in
            continuation.resume(returning: items)
        }
    }
}

Функция withCheckedContinuation() оборачивает собой вызов метода fetchLatestNews. Сам вызов этого метода сопровождается раскрытием блока замыкания, который возвращает нам список новостей. Чтобы вернуть этот список в качестве результата работы родительской функции, во вложенном блоке замыкания мы обращаемся к объекту continuation, который доступен нам из блока функции withCheckedContinuation(). Из данного свойства в свою очередь можно вызывать метод resume(returning:), чтобы отправить результат работы обратно в тот момент, когда он вам понадобиться, даже если это происходит в блоке замыкания, как в нашем случае.

Теперь мы можем получить нашу исходную функциональность в асинхронной функции:

func printNews() async {
    let items = await fetchLatestNews()

    for item in items {
        print(item)
    }
}

Название метода withCheckedContinuation() связано с тем, что в нем происходит проверка на то, что метод resume() был действительно вызван и был вызван не более одного раза. Это важно, потому что, если метод не вызывать, то это приведет к утечке ресурсов. Если же его вызвать больше одного раза, то скорее всего у вас возникнут проблемы.

Важно: Метод resume() должен быть вызван не больше одного раза и должен быть вызван обязательно.

Поскольку проверка continuations связана с производительностью во время выполнения, Swift также предоставляет функцию withUnsafeContinuation(), которая работает точно так же, за исключением того, что во время её выполнения проверки не происходит. Это означает, что Swift никак не предупредит вас, если вы вдруг забудете вызывать метод resume(). Если же вы вызовете его дважды, поведение будет неопределенным.

Акторы

Предложение SE-0306 представляет новый тип данных - Актор. Акторы концептуально похожи на классы, которые можно безопасно использовать в параллельных средах. Это стало возможным, потому что Swift теперь гарантирует, что изменять внутреннее состояние актора можно только из одного потока одновременно, что помогает устранить множество серьезных ошибок прямо на уровне компилятора.

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

class RiskyCollector {
    var deck: Set

    init(deck: Set) {
        self.deck = deck
    }

    func send(card selected: String, to person: RiskyCollector) -> Bool {
        guard deck.contains(selected) else { return false }

        deck.remove(selected)
        person.transfer(card: selected)
        return true
    }

    func transfer(card: String) {
        deck.insert(card)
    }
}

Если метод send(card:to:) вызвать более одного раза в одно и тоже время (это возможно сделать из разных потоков), то может произойти следующая цепочка событий:

  1. В первом потоке начинается проверка на то, есть ли карта в колоде, после чего продолжается выполнение кода в зависимости от результата.
  2. Второй вызов метода создает новый поток поток, в котором так же выполняется проверка на содержимое карты в колоде. Выполнение кода продолжается так же в соответствии с результатом.
  3. В это время первый поток удаляет карту из колоды и передает ее в другую колоду.
  4. Затем второй поток пытается удалить из колоды эту же карту, хотя её уже там нет, а далее передает её в другую колоду и таким образом мы получаем две одинаковые карты в одной колоде.

В этом случае первый коллекционер отдает одну карту, когда второй получает две.

Акторы решают эту проблему при помощи изоляции своего внутреннего состояния. Это значит, что свойства и методы акторов не могут быть прочитаны извне, если они не выполняются асинхронно. Изменять значения свойств акторов извне вообще невозможно ни при каких обстоятельствах. При этом асинхронное поведение никак не влияет на производительность, так как Swift автоматически помещает все запросы в последовательную очередь, что позволяет избежать той самой гонки, о которой мы говорили выше, когда два или более потоков борются за выполнение одного и того же блока кода.

Принимая во внимание все вышесказанное, вместо класса RiskyCollector мы можем использовать актор SafeCollector:

actor SafeCollector {
    var deck: Set

    init(deck: Set) {
        self.deck = deck
    }

    func send(card selected: String, to person: SafeCollector) async -> Bool {
        guard deck.contains(selected) else { return false }

        deck.remove(selected)
        await person.transfer(card: selected)
        return true
    }

    func transfer(card: String) {
        deck.insert(card)
    }
}

В этом примере следует обратить внимание на несколько моментов:

  1. Акторы создаются с использованием нового ключевого слова actor, которое определяет новый тип данных. Это такой же полноценный тип, как структуры, классы и перечисления.
  2. Метод send() помечен как async, потому что ему нужно будет приостановить свою работу, ожидая завершения передачи.
  3. Хотя метод transfer(card:) не помечен как async, нам все равно нужно вызывать его с помощью await, потому что он должен дождаться завершения запроса, отправленного другим экземпляром актора SafeCollector.

Для ясности: экземпляр актора может использовать свои собственные свойства и методы как угодно (синхронно или асинхронно), но при взаимодействии с другим экземпляром актора вызовы всегда должны выполняться асинхронно. Благодаря этим изменениям Swift может гарантировать, что все изолированные свойства и методы акторов никогда не будут доступны одновременно из разных потоков. И что более важно, это делается во время компиляции, чтобы гарантировать безопасность.

Помимо изолированного состояния акторов, от классов их отличают еще два важных момента:

    • Акторы в настоящее время не поддерживают наследование. Это в свою очередь упрощает работу с инициализаторами, так как отпадает надобность в создании в convenience инициализаторов. Так же не понадобится переопределять свойства и методы, не придется использовать ключевое слово final и т.д. Но это пока, в будущем это возможно изменится.
    • Все акторы неявно соответствуют новому протоколу Actor. Ни один другой тип не может быть подписан под этот протокол. Таким образом можно ограничить определенный блок кода так, чтобы он мог работать исключительно с акторами.

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

Акторы характеризуются: внутренним состоянием, которое не доступно извне, взаимодействием с другими акторами через асинхронные функции и действием в соответсвии с событиями, полученными от других акторов.

Глобальные акторы

Предложение SE-0316 позволяет изолировать с помощью акторов глобальное состояние потока данных от состояния гонки.

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

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

class StorageManager {
    func save() -> Bool {
        guard Thread.isMainThread else { return false }

        print("Saving data…")
        return true
    }
}

В текущем исполнении все будет работать правильно, но с помощью @MainActor мы можем гарантировать, что метод save() всегда будет вызываться только из основного потока, как если бы мы специально вызывали его при помощи DispatchQueue.main:

class StorageManager {
    @MainActor func save() {
        print("Saving data…")
    }
}

Это все, что нужно - Swift гарантирует, что метод save() будет вызываться только из основного потока.

Примечание: Поскольку вызов метода будет выполняться через актор, то его нужно вызывать с использованием ключевых слов await, async let и т.д.

@MainActor - это глобальная оболочка актора вокруг базовой структуры MainActor, которая полезна тем, что имеет статический метод run(), позволяющий планировать выполнение работы. Метод запускает любое действие в основном потоке и при необходимости может вернуть результат.

Протокол Sendable и оболочка @Sendable

SE-0302 добавляет поддержку «отправляемых» данных, то есть данных, которые можно безопасно передавать в другой поток. Это достигается с помощью нового протокола Sendable и атрибута @Sendable для функций.

К таким данным относятся:

  • Все базовые типы данных, такие как Bool, Int, String и т.д.
  • Опциональные типы данных, если они являются типами значений.
  • Любые типы коллекций, в качестве элементов которых выступают типы значений (Array<String>, Dictionary<Int, String>).
  • Кортежи, в которых все элементы являются типами значений.
  • Метатипы, такие как String.self.

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

Что же касается пользовательских типов данных, то тут все зависит от того, чем конкретно они являются:

  • Протоколу Sendable, автоматически соответствуют все акторы.
  • Пользовательские структуры и перечисления, также автоматически соответствуют Sendable протоколу, если только они не содержат внутри себя ссылочные типы данных.
  • В том случае, если пользовательские классы не наследуются от других классов или наследуются только от NSObject, и при этом помечены ключевым словом final, а также имеют в качестве свойств только типы значений, то в этом случае они тоже могут соответствовать Sendable протоколу.

Атрибут @Sendable в функциях или замыканиях, позволяет выполнять код параллельно, но с определенными ограничениями, для того, чтобы разработчики не стреляли сами себе в ноги:

func printScore() async {
    let score = 1

    Task { print(score) }
    Task { print(score) }
}

В данном примере действие, которое мы передаем в инициализатор Task, по умолчанию помечено как @Sendable. Данный атрибут позволяет выполнять задачи параллельно в разных потоках. А это возможно благодаря тому, что, что свойство score, в теле блока замыкания Task, является константой. Если бы свойство score было переменной, то доступ к ней могла бы получить одна из задач, в то время как другая может менять значение этой переменной.

Атрибутом @Sandable можно помечать и свои собственный функции и замыкания:

func runLater(_ completionHandler: @escaping @Sendable () -> Void) -> Void {
    DispatchQueue.global().asyncAfter(deadline: .now() + 3, execute: completionHandler)
}

Работа с такими функциями должна выполняться по тем же правилам.

#if для постфиксных членов выражений

SE-0308 позволяет Swift использовать условия #if в выражениях с постфиксными членами. Звучит непонятно, поэтому проще будет показать на примере, который решает проблему, обычно наблюдаемую в SwiftUI:

Text("Welcome")
#if os(iOS)
    .font(.largeTitle)
#else
    .font(.headline)
#endif

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

Можно делать вложенные условия, хотя они довольно сложны для восприятия:

#if os(iOS)
    .font(.largeTitle)
    #if DEBUG
        .foregroundColor(.red)
    #endif
#else
    .font(.headline)
#endif

При желании можно использовать совершенно разные постфиксные выражения:

let result = [1, 2, 3]
#if os(iOS)
    .count
#else
    .reduce(0, +)
#endif

print(result)

Технически мы можем получить результат в виде двух совершенно разных типов, хотя эта идея не очень-то и хорошая. Чего точно нельзя сделать, так это использовать выражения, которые не являются постфиксными членами, т.е. если они не вызываются через точку, например + [4] вместо .count.

Взаимозаменяемое использование типов CGFloat и Double.

SE-0307 представляет небольшое, но важное улучшение: Swift теперь может неявно конвертировать значения с типом CGFloat в Double в большинстве мест, где это может понадобиться:

let first: CGFloat = 42
let second: Double = 19
let result = first + second
print(result) // Получаем итоговый результат с типом Double

Swift использует для этого неявный инициализатор, и всегда будет отдавать предпочтение Double, если это возможно. Что еще более важно, ничего из этого не достигается путем переписывания существующих API: технически такие вещи, как scaleEffect() в SwiftUI, по-прежнему работают с CGFloat, но Swift незаметно приводит его к Double.

Совместимость Codable и перечислений со связанными значениями

SE-0295 улучшает протокол Codable до поддержки перечислений со связанными значениями. Ранее перечисления поддерживались только в том случае, если они соответствовали протоколу RawRepresentable. Но текущее обновление расширяет поддержку всех перечислений, в том числе и перечислений с любым количеством связанных значений:

enum Weather: Codable {
    case sun
    case wind(speed: Int)
    case rain(amount: Int, chance: Int)
}

Данное перечисление имеет один кейс без каких либо значений, второй кейс имеет одно связанное значение с типом Int и третий кейс с двумя связанными целочисленными значениями.

Создадим массив с прогнозом погоды на базе этого перечисления, чтобы затем конвертировать его в JSON:

let forecast: [Weather] = [
    .sun,
    .wind(speed: 10),
    .sun,
    .rain(amount: 5, chance: 50)
]

do {
    let result = try JSONEncoder().encode(forecast)
    let jsonString = String(decoding: result, as: UTF8.self)
    print(jsonString)
} catch {
    print("Encoding error: \\(error.localizedDescription)")
}

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

lazy в локальной зоне видимости

Ключевое слово lazy (ленивый) позволяет осуществлять отложенную инициализацию свойства. Обратиться к ленивому свойству невозможно до тех пор, пока инициализация класса полностью не завершится. Раньше ключевым словом lazy можно было пометить только свойства класса. Начиная с версии Swift 5.5, lazy можно использовать и в локальных зонах видимости методов:

func printGreeting(to: String) -> String {
    print("In printGreeting()")
    return "Hello, \\(to)"
}

func lazyTest() {
    print("Before lazy")
    lazy var greeting = printGreeting(to: "swiftbook")
    print("After lazy")
    print(greeting)
}

lazyTest()

При вызове метода lazyTest на консоль сначала выйдут сообщения «Before lazy» и «After lazy», затем «In printGreeting()» и уже в самом конце «Hello, swiftbook». Вызов метода printGreeting(to:) будет осуществлен в тот момент, когда мы передадим результат его работы в строке print(greeting).

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

Оболочки над свойствами для параметров функций и замыканий

Благодаря предложению SE-0293 оболочки над свойствами теперь можно применять к параметрам функций и замыканий. Параметры, переданные таким образом, как и обычные параметры остаются неизменными, и вы также можете получить доступ к базовому типу оболочки свойств, используя нижнее подчеркивание.

В качестве примера мы могли бы написать функцию, которая принимает целое число и выводит его на консоль:

func setScore1(to score: Int) {
    print("Setting score to \\(score)")
}

Соответсвенно, в параметр данной функции мы можем передать любое целочисленное значение практически из любого диапазона, в том числе и отрицательное:

setScore1(to: 50)
setScore1(to: -50)
setScore1(to: 500)

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

@propertyWrapper
struct Clamped {
    let wrappedValue: T

    init(wrappedValue: T, range: ClosedRange) {
        self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

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

func setScore2(@Clamped(range: 0...100) to score: Int) {
    print("Setting score to \\(score)")
}

setScore2(to: 50) // 50
setScore2(to: -50) // 0
setScore2(to: 500) // 100

Расширение поиска статических членов в дженериках

SE-0299 позволяет Swift выполнять статический поиск членов протоколов в дженерик функциях. Опять таки звучит непонятно, поэтому сразу рассмотрим пример. В SwiftUI для определения внешнего вида некоторых элементов интерфейса, таких как граница текстового поля или внешний вид Toggle в модификаторы приходится передавать названия структур, отвечающих за внешний вид:

Toggle("Example", isOn: .constant(true))
    .toggleStyle(SwitchToggleStyle())

С обновлением языка это должно выглядеть примерно так:

Toggle("Example", isOn: .constant(true))
    .toggleStyle(.switch)

Но на самом деле в настоящее время SwiftUI пока не поддерживает это нововведение, хотя если все пойдет по плану, то скоро мы его увидим. В более ранних бетах SwiftUI это выглядело именно так, но перед релизом Apple отозвало эту фичу.

Но пример, который мы рассмотрели выше не единственный. Уже сейчас это нововведение можно использовать следующим образом:

protocol Theme { }
struct LightTheme: Theme { }
struct DarkTheme: Theme { }
struct RainbowTheme: Theme { }

Здесь у нас представлен протокол Theme и три структуры, которые под него подписаны. Так же у нас есть протокол Screen, в расширении которого мы реализуем дженерик метод с типом удовлетворяющим протоколу Theme и возвращающий объект Screen с определенной темой:

protocol Screen { }

extension Screen {
    func theme(_ style: T) -> Screen {
        print("Activating new theme!")
        return self
    }
}

Создадим структуру HomeScreen, удовлетворяющую протоколу Screen:

struct HomeScreen: Screen { }

До появления текущего нововведения мы могли бы включить светлую тему для домашнего экрана следующим образом:

let lightScreen = HomeScreen().theme(LightTheme())

Чтобы упростить доступ к светлой теме, мы могли бы определить статическое свойство light в протоколе Theme следующим образом:

extension Theme where Self == LightTheme {
    static var light: LightTheme { .init() }
}

Однако использование такого подхода вкупе с дженерик методом theme() нашего протокола до версии Swift 5.5 было невозможно, поэтому приходилось подставлять LightTheme() каждый раз. Однако теперь это стало возможным:

let lightTheme = HomeScreen().theme(.light)

И напоследок...

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

Хотя я попытался охватить все основные новые функции Swift 5.5, есть еще кое-что, о чем я не рассказал:

Конечно, нам еще предстоит увидеть, как эти изменения повлияют на Foundation, SwiftUI и другие фреймворки Apple - а я уверен, что это произойдет. Фактически всё здесь указывает на то, что впереди нас ждут существенные изменения в разработке под iOS. Частично в результате текущих изменений, частично из-за того, что эти изменения повлияют на еще не анонсированный API, а частично потому, что Apple провела огромную работу по обеспечению обратной совместимости concurrency концепций с более ранними версиями ОС.

Учитывая такое огромное количество изменений, кажется странным, что новая версия Swift имеет номер 5.5 - а не шесть. Возможно Apple планирует Swift 6.0, когда к релизу будет готов второй этап текущих изменений. Я не обсуждал их здесь, потому что эти изменения по большей части не относятся к версии Swift 5.5. Но в ближайшем будущем вторая фаза изоляции акторов, скорее всего вызовет существенное изменение в коде, которое будет оправдано для мажорной шестой версии языка.

В любом случае, я не собираюсь здесь спекулировать дальше, просто хочу сказать, что с нетерпением жду возможности перечитать эту статью после WWDC21, потому что подозреваю, что мы увидим ключевые части фреймворков Apple, уже адаптированные под Swift 5.5.

Что я могу сказать точно, так это то, что команда, которая трудится над Swift, работает невероятно усердно, чтобы предоставить потрясающую коллекцию изменений за относительно короткое время. И очень многим мы обязаны комьюнити Swift Evolution. Чтобы в Swift появились акторы потребовалось сделать семь презентаций и два предложения, прежде чем это утвердили.

Итак, команде Swift: спасибо за то, что вы действительно сделали все возможное, чтобы создать что-то невероятное в сжатые сроки. И всем остальным: пристегнитесь, потому что WWDC21 будет чертовски крутой…

Первоисточник

Комментарии

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: