Что нового в Swift 5.9?

Макросы, выражения if и switch, некопируемые типы и многое другое!

Хотя Swift 6 маячит на горизонте, выпуски 5.x все еще могут многое предложить — более простые способы использования if и switch, макросы, некопируемые типы, пользовательские исполнители акторов и многое другое — все это появится в Swift 5.9, что делает еще один выпуск мамонтом.

В этой статье я расскажу вам о наиболее важных изменениях в этом выпуске, предоставив примеры кода и пояснения, чтобы вы могли попробовать все это самостоятельно. Вам понадобится последняя версия набора инструментов Swift 5.9, установленная в Xcode 14, или бета-версия Xcode 15.

Выражения if и switch

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

В качестве простого примера мы могли бы установить для переменной значение «Пройдено» или «Не пройдено» в зависимости от такого условия:

let score = 800
let simpleResult = if score > 500 { "Pass" } else { "Fail" }
print(simpleResult)

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

let complexResult = switch score {
    case 0...300: "Fail"
    case 301...500: "Pass"
    case 501...800: "Merit"
    default: "Distinction"
}

print(complexResult)

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

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

func rating(for score: Int) -> String {
    switch score {
    case 0...300: "Fail"
    case 301...500: "Pass"
    case 501...800: "Merit"
    default: "Distinction"
    }
}

print(rating(for: score))

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

let ternaryResult = score > 500 ? "Pass" : "Fail"
print(ternaryResult)

Однако они не идентичны, и есть одно место, которое может вас зацепить — вы можете увидеть его в этом коде:

let customerRating = 4
let bonusMultiplier1 = customerRating > 3 ? 1.5 : 1
let bonusMultiplier2 = if customerRating > 3 { 1.5 } else { 1.0 }

Оба этих вычисления дают значение Double со значением 1,5, но обратите внимание на альтернативное значение для каждого из них: для троичной опции я написал 1, а для выражения if я написал 1,0.

Это сделано намеренно: при использовании тернарного Swift проверяет типы обоих значений одновременно и поэтому автоматически считает 1 равным 1,0, тогда как с выражением if два параметра проверяются независимо друг от друга: если мы используем 1,5 для одного случая и 1 для другого мы будем отправлять обратно Double и Int, что не разрешено.

Типы пакетов параметра и значения

SE-0393, SE-0398 и SE-0399 вместе образуют довольно плотный узел улучшений Swift, которые позволяют нам использовать вариативные дженерики.

Это довольно продвинутая функция, поэтому позвольте мне подытожить ее так, чтобы большинство людей прислушались: это почти наверняка означает, что старое ограничение в 10 просмотров в SwiftUI вот-вот исчезнет. Мы узнаем об этом только завтра, когда все станет достоянием общественности, но я был бы искренне удивлен, если бы проблема осталась в iOS 17.

Все еще здесь? Хорошо. Эти предложения решают важную проблему в Swift, заключающуюся в том, что универсальные функции требуют определенного количества параметров типа. Эти функции по-прежнему могли принимать вариативные параметры, но в конечном итоге им все равно приходилось использовать один и тот же тип.

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

struct FrontEndDev {
    var name: String
}

struct BackEndDev {
    var name: String
}

struct FullStackDev {
    var name: String
}

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

Мы могли бы создать экземпляры этих структур следующим образом:

let johnny = FrontEndDev(name: "Johnny Appleseed")
let jess = FrontEndDev(name: "Jessica Appleseed")
let kate = BackEndDev(name: "Kate Bell")
let kevin = BackEndDev(name: "Kevin Bell")

let derek = FullStackDev(name: "Derek Derekson")

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

func pairUp1<T, U>(firstPeople: T..., secondPeople: U...) -> ([(T, U)]) {
    assert(firstPeople.count == secondPeople.count, "You must provide equal numbers of people to pair.")
    var result = [(T, U)]()

    for i in 0..<firstPeople.count {
        result.append((firstPeople[i], secondPeople[i]))
    }

    return result
}

Он использует два переменных параметра для получения группы первых людей и группы вторых людей, а затем возвращает их в виде массива.

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

let result1 = pairUp1(firstPeople: johnny, jess, secondPeople: kate, kevin)

Пока что это старо, но вот что становится интересным: Дерек — разработчик полного стека, поэтому он может работать как бэкенд-разработчиком, так и фронтенд-разработчиком. Однако если бы мы попытались использовать johnny, derek в качестве первого параметра, то Swift отказался бы собирать наш код — ему нужно, чтобы типы всех первых и вторых людей были одинаковыми.

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

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

func pairUp2<each T, each U>(firstPeople: repeat each T, secondPeople: repeat each U) -> (repeat (first: each T, second: each U)) {
    return (repeat (each firstPeople, each secondPeople))
}

Там происходят четыре независимых вещи, поэтому давайте рассмотрим их одну за другой:

  1. <each T, each U> создает два пакета параметров типа, T и U.
  2. repeat each T — это расширение пакета, которое расширяет пакет параметров до фактических значений — это эквивалент T…, но позволяет избежать некоторой путаницы с …, используемым в качестве оператора.
  3. Тип возвращаемого значения означает, что мы отправляем обратно кортежи парных программистов, по одному от T и U.
  4. Наше ключевое слово return — это то, что делает настоящую работу: оно использует выражение раскрытия пакета, чтобы взять одно значение из T и одно из U, объединяя их в возвращаемое значение.

Чего он не показывает, так это того, что возвращаемый тип автоматически гарантирует, что наши типы T и U имеют одинаковую форму — внутри них одинаковое количество элементов. Таким образом, вместо использования assert(), как в первой функции, Swift просто выдаст ошибку компилятора, если мы попытаемся передать два набора данных разного размера.

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

let result2 = pairUp2(firstPeople: johnny, derek, secondPeople: kate, kevin)

Что мы на самом деле сделали, так это реализовали простую функцию zip(), а это значит, что мы можем писать такую ерунду:

let result3 = pairUp2(firstPeople: johnny, derek, secondPeople: kate, 556)

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

protocol WritesFrontEndCode { }
protocol WritesBackEndCode { }

Затем добавьте некоторые соответствия:

  • FrontEndDev должен соответствовать WritesFrontEndCode.
  • BackEndDev должен соответствовать WritesBackEndCode.
  • FullStackDev должен соответствовать как WritesFrontEndCode, так и WritesBackEndCode.

И теперь мы можем добавить ограничения в наши пакеты параметров типа:

func pairUp3<each T: WritesFrontEndCode, each U: WritesBackEndCode>(firstPeople: repeat each T, secondPeople: repeat each U) -> (repeat (first: each T, second: each U)) {
    return (repeat (each firstPeople, each secondPeople))
}

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

Чтобы перенести это на то, с чем вы, скорее всего, знакомы, у нас есть аналогичная ситуация в SwiftUI. Мы регулярно хотим иметь возможность создавать представления со многими подпредставлениями, и если бы мы работали с одним типом представления, таким как текст, то вы могли бы представить что-то вроде текста… отлично работает. Но это не сработало бы, если бы мы хотели иметь какой-то текст, затем изображение, затем кнопку и так далее — любой неоднородный макет был бы просто невозможен.

Попытка использовать AnyView… или что-то подобное для стирания типов отбрасывает всю информацию о типах, поэтому до Swift 5.9 эта проблема решалась путем создания множества перегрузок функций. Например, построитель представлений SwiftUI имеет перегруженные версии buildBlock(), которые могут комбинировать два представления, три представления, четыре представления и т. д., вплоть до 10 представлений, но не больше, потому что им нужно где-то провести линию.

Итак, мы получили ограничение в 10 просмотров в SwiftUI, и, скрестим пальцы, это должно исчезнуть…

Макросы

SE-0382, SE-0389 и SE-0397 объединяются для добавления макросов в Swift, которые позволяют нам создавать код, преобразующий синтаксис во время компиляции.

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

Макросы в чем-то вроде C++ — это способ предварительной обработки вашего кода — для эффективной замены текста в коде до того, как его увидит основной компилятор, чтобы вы могли генерировать код, который вы действительно не хотите писать вручную.

Макросы Swift похожи, но значительно мощнее и, следовательно, значительно сложнее. Они также позволяют нам динамически манипулировать кодом Swift нашего проекта до его компиляции, что позволяет нам внедрять дополнительные функции во время компиляции.

Главное, что нужно знать:

  • Они безопасны для типов, а не простые замены строк, поэтому вам нужно точно указать макросу, с какими данными он будет работать.
  • Они запускаются как внешние программы на этапе сборки и не находятся в вашем основном целевом приложении.
  • Макросы разбиты на несколько более мелких типов, таких как ExpressionMacro для создания одного выражения, AccessorMacro для добавления методов получения и установки и ConformanceMacro для приведения типа в соответствие с протоколом.
  • Макросы работают с вашим проанализированным исходным кодом — мы можем запрашивать отдельные части кода, такие как имя свойства, которым мы манипулируем, или его типы, или различные свойства внутри структуры.
  • Они работают внутри песочницы и должны работать только с теми данными, которые им предоставлены.

Последняя часть особенно важна: поддержка макросов Swift построена на библиотеке Apple SwiftSyntax для понимания исходного кода и управления им. Вы должны добавить это как зависимость для ваших макросов.

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

Сначала нам нужно создать код, выполняющий раскрытие макроса — то, что превратит #buildDate во что-то вроде 2023-06-05T18:00:00Z:

public struct BuildDateMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        let date = ISO8601DateFormatter().string(from: .now)
        return "\"\(raw: date)\""
    }
}

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

Внутри того же модуля мы создаем структуру, которая соответствует протоколу CompilerPlugin, экспортируя наш макрос:

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct MyMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        BuildDateMacro.self
    ]
}

Затем мы добавим это в наш список целей в Package.swift:

.macro(
  name: "MyMacrosPlugin",
  dependencies: [
    .product(name: "SwiftSyntax", package: "swift-syntax"),
    .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
    .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
  ]
),

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

Это занимает два шага, начиная с определения макроса. В нашем случае это отдельный макрос-выражение, возвращающий строку, он существует внутри модуля MyMacrosPlugin и имеет строгое имя BuildDateMacro. Итак, мы добавим это определение к нашей основной цели:

@freestanding(expression)
macro buildDate() -> String =
  #externalMacro(module: "MyMacrosPlugin", type: "BuildDateMacro")

И второй шаг — использовать макрос, например:

print(#buildDate)

Когда вы читаете этот код, самое важное, что нужно усвоить, это то, что основная функциональность макросов — весь этот код внутри структуры BuildDateMacro — запускается во время сборки, а его результаты вводятся обратно в сайты вызовов. Таким образом, наш небольшой вызов print() выше будет переписан примерно так:

print("2023-06-05T18:00:00Z")

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

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

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

Например, если у вас есть наблюдаемый объект, который использует @Published для каждого из своих свойств, вы можете написать простой макрос @AllPublished, который сделает эту работу за вас. Сначала напишем сам макрос:

public struct AllPublishedMacro: MemberAttributeMacro {
    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingAttributesFor member: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [AttributeSyntax] {
        [AttributeSyntax(attributeName: SimpleTypeIdentifierSyntax(name: .identifier("Published")))]
    }
}

Во-вторых, включите это в свой список предоставленных макросов:

struct MyMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        BuildDateMacro.self,
        AllPublishedMacro.self,
    ]
}

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

@attached(memberAttribute)
macro AllPublished() = #externalMacro(module: "MyMacrosPlugin", type: "AllPublishedMacro")

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

@AllPublished class User: ObservableObject {
    var username = "Taylor"
    var age = 26
}

Наши макросы могут принимать параметры для управления их поведением, хотя здесь сложность действительно может взлететь вверх. Например, Дуг Грегор из команды Swift поддерживает небольшой репозиторий GitHub с примерами макросов, в том числе один аккуратный, который проверяет правильность жестко закодированных URL-адресов во время сборки — становится невозможным ввести URL-адрес неправильно, потому что сборка не будет продолжить.

Объявить макрос в нашей цели приложения очень просто, включая добавление строкового параметра:

@freestanding(expression) public macro URL(_ stringLiteral: String) -> URL = #externalMacro(module: "MyMacrosPlugin", type: "URLMacro")

Использовать его также просто:

let url = #URL("https://swift.org")
print(url.absoluteString)

Это превращает url в полный URL-адрес, а не в необязательный, потому что мы проверяем правильность URL-адреса во время компиляции.

Что сложнее, так это сам макрос, который должен прочитать переданную строку «https://swift.org» и преобразовать ее в URL-адрес. Версия Дуга более основательна, но если мы сведем ее к минимуму, то получим следующее:

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression,
              let segments = argument.as(StringLiteralExprSyntax.self)?.segments
        else {
            fatalError("#URL requires a static string literal")
        }

        guard let _ = URL(string: segments.description) else {
            fatalError("Malformed url: \(argument)")
        }

        return "URL(string: \(argument))!"
    }
}

SwiftSyntax прекрасен, но я бы не назвал его обнаруживаемым.

Прежде чем двигаться дальше, я хочу добавить еще три вещи.

Во-первых, значение MacroExpansionContext, которое мы получили, имеет очень полезный метод makeUniqueName(), который создаст новое имя переменной, которое гарантированно не будет конфликтовать ни с какими другими именами в текущем контексте. Если вы хотите внедрить новые имена в готовый код, makeUniqueName() — разумный ход.

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

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

Некопируемые структуры и перечисления

SE-0390 вводит концепцию структур и перечислений, которые нельзя копировать, что, в свою очередь, позволяет совместно использовать один экземпляр структуры или перечисления во многих местах — в конечном итоге они по-прежнему имеют одного владельца, но теперь к ним можно получить доступ в различных частях ваш код.

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

Во-первых, это изменение вводит новый синтаксис для подавления требования: ~Copyable. Это означает, что «этот тип нельзя скопировать», и этот синтаксис подавления недоступен в других местах в настоящее время — мы не можем использовать ~Equatable, например, чтобы отказаться от == для типа.

Таким образом, мы могли бы создать новую некопируемую структуру User следующим образом:

struct User: ~Copyable {
    var name: String
}

Примечание. Некопируемые типы не могут соответствовать никаким протоколам, кроме Sendable.

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

func createUser() {
    let newUser = User(name: "Anonymous")

    var userCopy = newUser
    print(userCopy.name)
}

createUser()

Но мы объявили структуру User некопируемой — как она может получить копию newUser? Ответ заключается в том, что не может: назначение newUser для userCopy приводит к тому, что исходное значение newUser потребляется, а это означает, что его больше нельзя использовать, поскольку право собственности теперь принадлежит userCopy. Если вы попытаетесь изменить print(userCopy.name) на print(newUser.name), вы увидите, что Swift выдает ошибку компилятора — это просто не разрешено.

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

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

func createAndGreetUser() {
    let newUser = User(name: "Anonymous")
    greet(newUser)
    print("Goodbye, \(newUser.name)")
}

func greet(_ user: borrowing User) {
    print("Hello, \(user.name)!")
}

createAndGreetUser()

Напротив, если бы мы заставили функцию приветствия() использовать пользователя, то печать («До свидания, \(newUser.name)») была бы недопустима — Swift счел бы значение newUser недействительным после запуска приветствия(). . С другой стороны, поскольку потребляющие методы должны завершать жизненный цикл объекта, они могут свободно изменять его свойства.

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

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

Во-первых, вот код, который использует деинициализатор с классом:

class Movie {
    var name: String

    init(name: String) {
        self.name = name
    }

    deinit {
        print("\(name) is no longer available")
    }
}

func watchMovie() {
    let movie = Movie(name: "The Hunt for Red October")
    print("Watching \(movie.name)")
}

watchMovie()

Когда он запускается, он печатает «Наблюдение за «Красным Октябрем», а затем «Охота за Красным Октябрем больше недоступна». Но если вы измените определение типа с class Movie на struct Movie: ~Copyable, вы увидите, что эти два оператора print() выполняются в обратном порядке: сначала сообщается, что фильм больше недоступен, а затем говорится, что его смотрят.

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

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

struct MissionImpossibleMessage: ~Copyable {
    private var message: String

    init(message: String) {
        self.message = message
    }

    consuming func read() {
        print(message)
    }
}

Это помечает само сообщение как приватное, поэтому к нему можно получить доступ, только вызвав метод read(), который использует экземпляр.

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

func createMessage() {
    let message = MissionImpossibleMessage(message: "You need to abseil down a skyscraper for some reason.")
    message.read()
}

createMessage()

Примечание. Поскольку message.read() использует экземпляр message, попытка вызвать message.read() во второй раз является ошибкой.

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

Чтобы избежать этой проблемы, Swift 5.9 представляет новый оператор discard, который можно использовать для использования методов некопируемых типов. Когда вы используете discard self в потребляющем методе, он останавливает запуск деинициализатора для этого объекта.

Итак, мы могли бы реализовать нашу структуру HighScore следующим образом:

struct HighScore: ~Copyable {
    var value = 0

    consuming func finalize() {
        print("Saving score to disk…")
        discard self
    }

    deinit {
        print("Deinit is saving score to disk…")
    }
}

func createHighScore() {
    var highScore = HighScore()
    highScore.value = 20
    highScore.finalize()
}

createHighScore()

Совет: когда этот код запустится, вы увидите, что сообщение деинициализации печатается дважды: один раз, когда мы меняем свойство value, что фактически уничтожает и воссоздает структуру, и один раз, когда метод createHighScore() завершает работу.

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

  • Классы и акторы не могут быть некопируемыми.
  • Некопируемые типы в настоящее время не поддерживают дженерики, что на данный момент исключает необязательные некопируемые объекты, а также массивы некопируемых объектов.
  • Если вы используете некопируемый тип в качестве свойства внутри другой структуры или перечисления, эта родительская структура или перечисление также должны быть некопируемыми.
  • Вы должны быть очень осторожны, добавляя или удаляя Copyable из существующих типов, потому что это резко меняет то, как они используются. Если вы отправляете код в библиотеку, это сломает ваш ABI.

Оператор потребления, чтобы завершить время жизни привязки переменной

SE-0366 расширяет концепцию использования значений на локальные переменные и константы копируемых типов, что может быть полезно разработчикам, которые хотят избежать избыточных вызовов retain/release, происходящих за кулисами при передаче их данных.

В простейшей форме оператор потребления выглядит так:

struct User {
    var name: String
}

func createUser() {
    let newUser = User(name: "Anonymous")
    let userCopy = consume newUser
    print(userCopy.name)
}

createUser()

Важная строка — это строка let userCopy, которая делает две вещи одновременно:

  1. Он копирует значение из newUser в userCopy.
  2. Это завершает время жизни newUser, поэтому любая дальнейшая попытка доступа к нему приведет к ошибке.

Это позволяет нам явно указать компилятору «не разрешать мне снова использовать это значение», и он применит правило от нашего имени.

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

func consumeUser() {
    let newUser = User(name: "Anonymous")
    _ = consume newUser
}

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

func createAndProcessUser() {
    let newUser = User(name: "Anonymous")
    process(user: consume newUser)
}

func process(user: User) {
    print("Processing \(name)…")
}

createAndProcessUser()

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

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

func greetRandomly() {
    let user = User(name: "Taylor Swift")

    if Bool.random() {
        let userCopy = consume user
        print("Hello, \(userCopy.name)")
    } else {
        print("Greetings, \(user.name)")
    }
}

greetRandomly()

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

func createThenRecreate() {
    var user = User(name: "Roy Kent")
    _ = consume user

    user = User(name: "Jamie Tartt")
    print(user.name)
}

createThenRecreate()

Удобные методы Async[Throwing]Stream.makeStream

SE-0388 добавляет новый метод makeStream() как к AsyncStream, так и к AsyncThrowingStream, который отправляет обратно как сам поток, так и его продолжение.

Итак, вместо того, чтобы писать такой код:

var continuation: AsyncStream<String>.Continuation!
let stream = AsyncStream<String> { continuation = $0 }

Теперь мы можем получить оба одновременно:

let (stream, continuation) = AsyncStream.makeStream(of: String.self)

Это будет особенно полезно в тех местах, где вам нужно получить доступ к продолжению вне текущего контекста, например, в другом методе. Например, ранее мы могли написать простой генератор чисел, подобный этому, который должен хранить продолжение как собственное свойство, чтобы иметь возможность вызывать его из метода queueWork():

struct OldNumberGenerator {
    private var continuation: AsyncStream<Int>.Continuation!
    var stream: AsyncStream<Int>!

    init() {
        stream = AsyncStream(Int.self) { continuation in
            self.continuation = continuation
        }
    }

    func queueWork() {
        Task {
            for i in 1...10 {
                try await Task.sleep(for: .seconds(1))
                continuation.yield(i)
            }

            continuation.finish()
        }
    }
}

С новым методом makeStream(of:) этот код становится намного проще:

struct NewNumberGenerator {
    let (stream, continuation) = AsyncStream.makeStream(of: Int.self)

    func queueWork() {
        Task {
            for i in 1...10 {
                try await Task.sleep(for: .seconds(1))
                continuation.yield(i)
            }

            continuation.finish()
        }
    }
}

Добавить sleep(for:) в часы

SE-0374 добавляет новый метод расширения к протоколу часов Swift, который позволяет нам приостановить выполнение на заданное количество секунд, а также расширяет спящий режим на основе продолжительности для поддержки определенного допуска.

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

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

class DataController: ObservableObject {
    var clock: any Clock<Duration>

    init(clock: any Clock<Duration>) {
        self.clock = clock
    }

    func delayedSave() async throws {
        try await clock.sleep(for: .seconds(1))
        print("Saving…")
    }
}

Поскольку при этом используется любой Clock<Duration>, теперь можно использовать что-то вроде ContinuousClock в производстве, но ваши собственные DummyClock в тестировании, где вы игнорируете все команды sleep(), чтобы ваши тесты выполнялись быстро.

В более старых версиях Swift теоретически эквивалентным кодом был бы try await clock.sleep(until: clock.now.advanced(by: .seconds(1))), но в этом примере это не сработает, потому что clock.now недоступен, поскольку Swift точно не знает, какие часы использовались.

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

try await Task.sleep(until: .now + .seconds(1), tolerance: .seconds(0.5))

Только к этому:

try await Task.sleep(for: .seconds(1), tolerance: .seconds(0.5))

Отказ от групп задач

SE-0381 добавляет новые отбрасываемые группы задач, которые устраняют важный пробел в текущем API: задачи, созданные внутри группы задач, автоматически отбрасываются и уничтожаются, как только они завершаются, что означает группы задач, которые выполняются в течение длительного периода времени (или возможно, навсегда, как в случае с веб-сервером) со временем не произойдет утечка памяти.

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

Чистого решения этой проблемы не существовало до версии Swift 5.9, в которой добавлены функции withDiscardingTaskGroup() и withThrowingDiscardingTaskGroup(), создающие новые отбрасывающие группы задач. Это группы задач, которые автоматически отбрасывают и уничтожают каждую задачу, как только она завершается, и нам не нужно вызывать next(), чтобы использовать ее вручную.

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

struct FileWatcher {
    // The URL we're watching for file changes.
    let url: URL

    // The set of URLs we've already returned.
    private var handled = Set<URL>()

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

    mutating func next() async throws -> URL? {
        while true {
            // Read the latest contents of our directory, or exit if a problem occurred.
            guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else {
                return nil
            }

            // Figure out which URLs we haven't already handled.
            let unhandled = handled.symmetricDifference(contents)

            if let newURL = unhandled.first {
                // If we already handled this URL then it must be deleted.
                if handled.contains(newURL) {
                    handled.remove(newURL)
                } else {
                    // Otherwise this URL is new, so mark it as handled.
                    handled.insert(newURL)
                    return newURL
                }
            } else {
                // No file difference; sleep for a few seconds then try again.
                try await Task.sleep(for: .microseconds(1000))
            }
        }
    }
}

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

struct FileProcessor {
    static func main() async throws {
        var watcher = FileWatcher(url: URL(filePath: "/Users/twostraws"))

        try await withThrowingTaskGroup(of: Void.self) { group in
            while let newURL = try await watcher.next() {
                group.addTask {
                    process(newURL)
                }
            }
        }
    }

    static func process(_ url: URL) {
        print("Processing \(url.path())")
    }
}

Это будет работать вечно или, по крайней мере, до тех пор, пока пользователь не завершит программу или каталог, который мы наблюдаем, не перестанет быть доступным. Однако, поскольку он использует withThrowingTaskGroup(), у него есть проблема: новая дочерняя задача создается каждый раз, когда вызывается addTask(), но поскольку она нигде не вызывает group.next(), эти дочерние задачи никогда не уничтожаются. Постепенно — может быть, всего несколько сотен байт каждый раз — этот код будет потреблять все больше и больше памяти, пока в конечном итоге операционная система не исчерпает ОЗУ и не будет вынуждена завершить программу.

Эта проблема полностью исчезает при отбрасывании групп задач: простая замена withThrowingTaskGroup(of: Void.self) на withThrowingDiscardingTaskGroup означает, что каждая дочерняя задача автоматически уничтожается, как только ее работа завершается.

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

И еще…

SE-0392 добавляет возможность создавать собственные исполнители акторов, что дает разработчикам детальный контроль над тем, как актор запускает свой код. Это функция, специально предназначенная для очень точных, очень сложных требований, и даже в предложении Swift Evolution говорится, что «ожидается, что пользовательские исполнители будут в основном реализованы экспертами».

До Swift 5.9 нас в основном не заботило, где выполняется параллельный код — мы не говорили «выполнить эту функцию в потоке X, а другую функцию — в потоке Y», а вместо этого позволяли Swift управлять этим за нас. Пользовательские исполнители позволяют нам быть более конкретными, возможно, потому, что мы хотим, чтобы группы акторов выполнялись в одном и том же потоке, или потому, что операционная система требует выполнения определенной работы в конкретном потоке.

Не будем забывать, что есть большая вероятность, что еще несколько предложений Swift Evolution появятся до окончательного выпуска 5.9. Один из них, за которым я внимательно слежу, это: SE-0395: Observability, который используется для поддержки SwiftData в iOS 17 и более поздних версиях — очевидно, что он пройдет через Evolution!

И если вы все еще используете много Objective-C в своем проекте, вполне возможно, что SE-0384 может вам помочь — если ему удастся превратить его в Swift 5.9. Это предложение улучшает код импорта Swift Objective-C, так что предварительные объявления становятся частично доступными в коде Swift.

Для людей, которые ранее не использовали Objective-C, предварительное объявление — это код, который говорит что-то вроде «класс с именем User будет существовать в какой-то момент, и хотя я хочу сослаться на него здесь, я определю, что он на самом деле содержит где-то». еще.»

В предыдущих версиях Swift эти предварительные объявления игнорировались вместе с любым кодом, который их использовал, что часто ограничивало объем импортируемого кода Objective-C. Однако, начиная со Swift 5.9 и далее — если предложение удается отправить вовремя — это меняется двумя важными способами:

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