Хотя Swift 6 уже не за горами, Apple продолжает добавлять новые и улучшенные функции в текущую версию Swift 5.x.

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

В этой статье разберем самые важные изменения этого релиза с примерами кода и пояснениями. Для воспроизведения приведенных в этой статье примеров вам понадобиться последняя версия Xcode 14 или Xcode 15 beta.

if и switch как выражения (if and switch expressions)

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

В программировании "выражение" - это блок кода, который возвращает значение при его выполнении. Раньше в Swift операторы if и switch использовались в основном для управления потоком выполнения кода, но не возвращали значения.

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

Пример:

let number = 5
let result = if number > 0 { "positive" } else { "negative or zero" }

В этом примере if выражение проверяет условие (number > 0), и возвращает значение ("positive" или "negative or zero"), которое затем присваивается переменной result.

Пример с использованием switch:

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

print(complexResult)

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

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

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))

Должно быть вы обратили внимание на то, что в самом первом примере со свойством result вместо if-else логичнее было бы применить тернарный оператор:

let result = number > 0 ? "positive" : "negative or zero"

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

let customerRating = 4
let bonusMultiplierOne = customerRating > 3 ? 1.5 : 1
let bonusMultiplierTwo = 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.

Пакеты параметров значений и типов (Value and Type Parameter Packs)

SE-0393SE-0398 и SE-0399 объединяются, чтобы представить целый ряд улучшений для Swift, позволяющих нам использовать вариативные обобщения (variadic generics).

Автор прогнозирует, что эти нововведения приведут к отказу от ограничения в 10 представлений в SwiftUI.

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

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

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")

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

func pairUp<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 index in 0..<firstPeople.count {
        result.append((firstPeople[index], secondPeople[index]))
    }

    return result
}

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

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

let result = pairUp(firstPeople: johnny, jess, secondPeople: kate, kevin)

Derek - full-stack разработчик и, следовательно, может работать как back-end, так и front-end разработчиком. Однако, если мы попытаемся использовать johnny в паре с derek, Swift откажется компилировать наш код - ему нужно, чтобы типы всех первых и вторых людей были одинаковыми.

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

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

func pairUp<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... из более старых версий Swift, но новый синтаксис избегает путаницы, связанной с использованием ... в качестве оператора.

  3. Тип возвращаемого значения (repeat (first: each T, second: each U)) означает, что функция возвращает кортежи, где каждый кортеж состоит из двух элементов - по одному из каждого пакета параметров T и U.

  4. Ключевое слово return выполняет реальную работу: оно использует выражение расширения пакета для взятия одного значения из T и одного из U, объединяя их в возвращаемое значение.

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

Теперь новая функция позволяет объединить Дерека (FullStackDev) с другими разработчиками (FrontEndDev или BackEndDev).:

let result = pairUp(firstPeople: johnny, derek, secondPeople: kate, kevin)

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

let result = pairUp(firstPeople: johnny, derek, secondPeople: kate, 556)

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

Пример с попыткой объединить Кевина (разработчика) и число 556 подчеркивает, что неограниченная гибкость может привести к нелогичным и ошибочным сценариям.

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

protocol WritesFrontEndCode { }
protocol WritesBackEndCode { }

В этом примере два протокола, WritesFrontEndCode и WritesBackEndCode, используются для определения поведения разработчиков. FrontEndDev должен соответствовать протоколу WritesFrontEndCodeBackEndDev - протоколу WritesBackEndCode, а FullStackDev - обоим протоколам, поскольку FullStackDev способен работать как с фронтендом, так и с бэкендом.

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

func pairUp<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))
}

В итоговом варианте функции типы T и U должны соответствовать протоколам WritesFrontEndCode и WritesBackEndCode соответственно, мы обеспечиваем логически корректное объединение разработчиков. Мы всегда получаем пару, в которой один разработчик может писать код для фронтенда, а другой - для бэкенда, независимо от того, являются ли они full-stack разработчиками или нет.

Это подчеркивает преимущества использования протоколов и параметров-пакетов в Swift для создания более безопасного и упорядоченного кода.

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

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

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

Макросы

SE-0382SE-0389 и SE-0397 привносят в Swift макросы. Макросы в контексте программирования - это специальные инструкции, которые расширяют возможности языка, трансформируя его синтаксис во время компиляции, т.е. перед тем, как он превращается в программу.

Это схоже с использованием автозамены в текстовом редакторе: вы можете ввести некоторую короткую строку (например, "с ув."), и редактор заменит её на другую, более длинную строку (например, "с уважением").

Некоторые языки программирования, такие как C и C++, широко используют макросы. Другие языки, включая многие современные, предпочитают избегать макросов, потому что они могут усложнить чтение и отладку кода, если те используются неаккуратно.

На сегодняшний день работать с макросами в Swift довольно сложно. Ниже представлено видение автора по наиболее эффективной работе с ними.

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

Главное, что нужно знать про макросы:

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

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

  • В Swift макросы классифицируются по различным типам, в зависимости от того, какую функцию они выполняют в коде. ExpressionMacro - это тип макроса, который используется для создания одного выражения в коде. Выражение - это фрагмент кода, который возвращает значение при выполнении. AccessorMacro - это тип макроса, который используется для добавления "геттеров" и "сеттеров" в код. ConformanceMacro - это тип макроса, который используется для автоматического добавления реализаций протоколов для типов.

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

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

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

Прежде всего, нам потребуется написать код, который будет обрабатывать макрос. Он должен будет превратить #buildDate во что-то типа 2023-15-05T16: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:

print(#buildDate)

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

Результат выполнения этого кода затем возвращается в места, откуда был вызван макрос. К примеру, команда print(#buildDate), где использовался макрос, будет преобразована в print("2023-15-05T16:00:00Z"), где "2023-15-05T16:00:00Z" это и есть результат выполнения макроса.

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

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

Давайте создадим более практичный пример макроса @AllPublished, который автоматически присвоит атрибут @Published каждому свойству в наблюдаемом объекте, сокращая таким образом объем работы и упрощая код.

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 member attribute macro". Это означает, что этот макрос будет применяться как атрибут к каждому члену (свойству или методу) класса или структуры, к которым он прикреплен. То есть, когда вы применяете этот макрос к определенному типу (классу, структуре и т.д.), он будет автоматически применяться ко всем его членам:

@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 (например, "https://swift.org"), и преобразует её в полноценный объект URL в коде. Это делается во время компиляции, что обеспечивает проверку правильности URL. Благодаря этому, URL в коде уже является полностью валидным и непустым (не nil), что упрощает дальнейшую работу с ним.

Основная сложность заключается в самом макросе, который должен корректно обработать переданную ему строку и преобразовать ее в 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, который указывает, что определенный тип данных не может быть скопирован. Таким образом, мы могли бы создать новую некопируемую структуру User следующим образом:

struct User: ~Copyable {
    let name: String
}

Это новшество особенное, так как аналогичный синтаксис, например для ~Equatable, который бы позволил нам отказаться от использования оператора == у определенного типа, в настоящее время не предусмотрен.

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

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

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

    let userCopy = user
    print(userCopy.name)
}

createUser()

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

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

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

С другой стороны, если параметр функции помечен как borrowing, т.е. "заимствует" некопируемое значение, то функция имеет доступ к этому значению, но не влияет на жизненный цикл этого значения и не выгружает его из памяти. Заимствование не передает владение (потребление) значением, что позволяет продолжать использовать это значение в других частях кода.

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

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

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

createAndGreetUser()

Функция greet имеет параметр user: borrowing User. Это значит, что значение экземпляра "заимствуется" и поэтому код в функции createAndGreetUser выведет на консоль сначала приветствие, а затем прощальное сообщение.

Если бы функция greet() "использовала" значение, то после её выполнения, экземпляр user выгрузился бы из памяти и попытка вызвать print("Goodbye, (newUser.name)") была бы недопустимой.

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

struct MissionImpossibleMessage: ~Copyable {
    private let message: String

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

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

Свойство message внутри структуры MissionImpossibleMessage помечено как приватное, и его можно получить только с помощью метода read(), который помечен ключевым словом consuming. Это значит, что функция "использует" (или потребляет) значение.

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

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

createMessage()

Потребляющие функции, берут на себя полное владение объектом и управляют его жизненным циклом, выгружая объект из памяти, после завершения своей работы. Это значит, что после вызова метода read(), экземпляр message выгрузится из памяти и больше не будет доступен. Попытка вызывать метод read() еще раз, приведет к ошибке.

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

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

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

final class Movie {
    let 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()

Когда функция watchMovie() вызывается, она сначала создает экземпляр класса Movie и выводит сообщение о просмотре фильма. Когда экземпляр movie выходит из области видимости функции и удаляется из памяти, вызывается деинициализатор класса Movie, который выводит сообщение о том, что фильм больше не доступен.

Однако, если изменить определение типа Movie с class на struct Movie: ~Copyable, порядок вывода сообщений поменяется. Сначала будет выведено сообщение о том, что фильм больше не доступен, и только затем сообщение о просмотре фильма.

По умолчанию методы внутри некопируемого типа помечены как borrowing (заимствующие), но при этом их так же можно пометить как mutating (изменяющие) или как concuming (использующие или потребляющие). Все это связано с новыми концепциями в Swift, которые позволяют более точно управлять жизненным циклом объектов.

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

Такое перекрещивание функций может привести к дублированию действий. Но Swift 5.9 предлагает решение этой проблемы - новый оператор discard. Если вы используете discard self в потребляющем методе, Swift пропустит выполнение деинициализатора для этого объекта. Таким образом, можно избежать ненужного дублирования действий.

Рассмотрим это на примере:

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().

У этой новой концепции есть некоторые нюансы, о которых важно помнить:

  1. Классы и акторы не могут быть некопируемыми.

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

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

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

Оператор consume для завершения жизненного цикла связывания переменной

Это улучшение вносит изменения в то, как Swift обрабатывает "потребление" значений переменных и констант.

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

struct User {
    var name: String
}

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

createUser()

В приведенном примере кода определяется структура User с одним свойством name, а затем объявляется функция createUser(), в которой создается новый экземпляр User.

Важной строкой в этом коде является строка let userCopy = consume user. Эта строка выполняет две задачи одновременно:

  1. Копирует значение из user в userCopy.

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

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

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

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

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

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

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

createAndProcessUser()

Приведенный выше пример содержит два важных аспекта:

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

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()
  1. Технически говоря, оператор consume работает с привязками, а не с самими значениями. На практике это означает, что если мы используем оператор consume с переменной, мы можем повторно инициализировать эту переменную и использовать ее без проблем:

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

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

createThenRecreate()

Convenience Async[Throwing]Stream.makeStream methods

Пропозал SE-0388 предлагает ввести новый метод makeStream() для создания экземпляров AsyncStream и AsyncThrowingStream. Этот метод позволяет вам получить одновременно поток (stream) и его продолжение (continuation).

AsyncStream и AsyncThrowingStream действуют как основные асинхронные последовательности, предлагаемые стандартной библиотекой.

После некоторого использования Async[Throwing]Stream стало ясно, что обычное применение включает передачу continuetion и Async[Throwing]Stream в разные места. Это требует вывода continuetion Async[Throwing]Stream.Continuation за пределы замыкания, которое передается инициализатору. Этот процесс не совсем удобен, так как он требует определенных манипуляций с неявно раскрытым опционалом. Кроме того, замыкание подразумевает, что время жизни continuetion ограничено замыканием, что на самом деле не так. Вот как выглядит пример использования текущего API AsyncStream:

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

Теперь с помощью makeStream() оба элемента можно получить одновременно:

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

Этот новый подход особенно полезен в случаях, когда продолжение (continuation) требуется использовать вне текущего контекста, например, в другом методе. Давайте для примера создадим старым способом простой генератор чисел, который должен сохранять continuetion как своё собственное свойство, чтобы иметь возможность вызывать его из метода 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 number in 1...10 {
                try await Task.sleep(for: .seconds(1))
                continuation.yield(number)
            }

            continuation.finish()
        }
    }
}

Структура OldNumberGenerator содержит два свойства: continuation и streamcontinuation это специальный объект, который позволяет добавить новые элементы в stream или закончить его.

В конструкторе init() создается stream и continuation. В блоке инициализации stream свойство continuation устанавливается в continuation, переданную в этот блок.

Метод queueWork() создает новую асинхронную задачу Task, которая в цикле от 1 до 10 каждую секунду добавляет новое число number в stream через continuation.yield(number). После завершения цикла вызывается continuation.finish(), что указывает, что в stream больше не будет добавлено новых элементов.

Заметьте, что код использует ключевое слово await перед Task.sleep(for: .seconds(1)), что означает, что операция "сна" является асинхронной и выполнение кода будет приостановлено на этом месте до окончания этой операции, не блокируя при этом выполнение остального кода.

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

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

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

            continuation.finish()
        }
    }
}

Структура NewNumberGenerator аналогична OldNumberGenerator, но использует удобный метод makeStream() для создания AsyncStream и его continuation сразу же.

Этот метод возвращает кортеж, содержащий stream и continuation, что позволяет их легко инициализировать в одной строке.

Метод queueWork() точно такой же, как и в предыдущем примере: он создает асинхронную задачу, которая генерирует числа от 1 до 10 каждую секунду и добавляет их в stream. После завершения цикла он вызывает continuation.finish(), указывая, что в stream больше не будет добавлено новых элементов.

Обратите внимание, что, в отличие от OldNumberGeneratorNewNumberGenerator не требует явного сохранения continuation как свойства, что делает код чище и проще для чтения и понимания.

Метод sleep(for:) для протокола Clock

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

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

Для примера создадим класс, который будет работать с типом Clock, и "заснет" на определенное время перед тем, как запустить операцию сохранения:

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

    // В инициализатор мы передаем экземпляр часов
    init(clock: any Clock<Duration>) {
        self.clock = clock
    }

    // Функция выполняет асинхронное сохранение с задержкой в 1 секунду
    func delayedSave() async throws {
        // Задержка выполнения операции
        try await clock.sleep(for: .seconds(1))
        print("Сохранение...")
    }
}

При этом мы можем использовать различные типы часов в разных контекстах (например, в тестовом и производственном окружении). В производственном окружении вы можете использовать ContinuousClock, который симулирует реальные часы. Однако в тестовом окружении вы можете использовать DummyClock, который игнорирует все команды sleep(), чтобы тесты выполнялись быстрее.

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

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

Что касается изменения в отношении "сна" у типа Task, это означает, что мы можем перейти от такого кода:

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

К этому:

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

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

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

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

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

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

struct FileWatcher {
    // URL, который мы отслеживаем на предмет изменения файлов.
    let url: URL

    // Набор URL-ов, которые мы уже обработали.
    private var handled: Set<URL> = []

    mutating func next() async throws -> URL? {
        while true {
            // Считываем последний контент из директории или выходим, если произошла ошибка.
            guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else {
                return nil
            }

            // Выясняем, какие URL-ы мы еще не обработали.
            let unhandled = handled.symmetricDifference(contents)

            if let newURL = unhandled.first {
                // Если мы уже обработали этот URL, то он, вероятно, был удален.
                if handled.contains(newURL) {
                    handled.remove(newURL)
                } else {
                    // В противном случае этот URL новый, поэтому помечаем его как обработанный.
                    handled.insert(newURL)
                    return newURL
                }
            } else {
                // Нет различий в файлах; спим несколько секунд, а затем пытаемся снова.
                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(), эти дочерние задачи никогда не удаляются после завершения работы. Это означает, что с течением времени программа начинает использовать все больше и больше оперативной памяти, что в конечном итоге может привести к исчерпанию доступной памяти и принудительному завершению работы программы операционной системой.

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

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

И это еще не всё...

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

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

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

Весьма вероятно, что в финальный релиз 5.9 войдут еще несколько предложений Swift Evolution:

Если предложение SE-0384 успеют добавить в релиз Swift 5.9, то оно улучшит механизм импорта Objective-C кода в Swift.

Важным элементом этого предложения является поддержка так называемых "предварительных объявлений" (или "forward declarations") из Objective-C. Предварительное объявление - это способ указать, что класс или протокол с определенным именем будет определен в более поздней части кода. В предыдущих версиях Swift forward declarations были проигнорированы, и любой код, который их использовал, не мог быть импортирован в Swift.

Это изменение повлияет на два основных момента:

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

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

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

Комментарии (0)