[Перевод] Swift 5.9. Что нового?
Хотя 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
.
Пример с использованием swifch
:
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-0393, SE-0398 и SE-0399 объединяются, чтобы представить целый ряд улучшений для Swift, позволяющих нам использовать вариативные обобщения (variadic generics).
Автор прогнозирует, что эти нововведения приведут к отказу от ограничения в 10 представлений в SwiftUI.
Эти предложения решают серьезную проблему в Swift, а именно то, что универсальные (generic) функции требовали определенного количества параметров типа. Эти функции все еще могли принимать вариативные параметры, но в конечном итоге все они должны были использовать один и тот же тип.
В качестве примера, у нас могут быть три разные структуры, которые представляют разные части нашей программы: FrontEndDev
, BackEndDev
и 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(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..
Эта функция использует два вариативных параметра для получения группы первых и вторых людей, а затем возвращает их в виде массива.
Теперь мы можем использовать эту функцию для создания пар программистов, которые могут работать вместе над 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(firstPeople: repeat each T, secondPeople: repeat each U) -> (repeat (first: each T, second: each U)) {
return (repeat (each firstPeople, each secondPeople))
}
Здесь происходит четыре независимых действия:
создает два пакета параметров типа,T
иU
. Суть в том, что каждый из этих типов может представлять собой произвольное количество аргументов.repeat each T
— это так называемое «расширение пакета», то есть преобразование пакета параметров в набор конкретных значений. Это похоже на синтаксисT...
из более старых версий Swift, но новый синтаксис избегает путаницы, связанной с использованием...
в качестве оператора.Тип возвращаемого значения
(repeat (first: each T, second: each U))
означает, что функция возвращает кортежи, где каждый кортеж состоит из двух элементов — по одному из каждого пакета параметровT
иU
.Ключевое слово
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
должен соответствовать протоколу WritesFrontEndCode
, BackEndDev
— протоколу WritesBackEndCode
, а FullStackDev
— обоим протоколам, поскольку FullStackDev
способен работать как с фронтендом, так и с бэкендом.
Теперь мы можем добавить ограничения к пакетам параметров типа:
func pairUp(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-0382, SE-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 так же вводит новые ограничения для параметров функций, принимающих некопируемые типы. Функции теперь должны явно указывать, собираются ли они «использовать» значение параметра или хотят его только «заимствовать».
Если параметр функции помечен как cunsuming
, значит функция будет «потреблять или использовать» некопируемое значение. В этом случае после завершения работы функции значение такого параметра выгружается из памяти и становится недоступным для дальнейшего использования.
С другой стороны, если параметр функции помечен как 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()
.
У этой новой концепции есть некоторые нюансы, о которых важно помнить:
Классы и акторы не могут быть некопируемыми.
Некопируемые типы пока не поддерживают обобщения (дженерики), что исключает возможность использования опциональных некопируемых объектов, а также массивов некопируемых объектов.
Если некопируемый тип используется в качестве свойства в другой структуре или перечислении, родительский тип тоже должен быть некопируемым.
Нужно быть очень осторожным при добавлении или удалении
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
. Эта строка выполняет две задачи одновременно:
Копирует значение из
user
вuserCopy
.Завершает существование
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()
Приведенный выше пример содержит два важных аспекта:
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()
Технически говоря, оператор
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.Continuation!
let stream = AsyncStream { continuation = $0 }
Теперь с помощью makeStream()
оба элемента можно получить одновременно:
let (stream, continuation) = AsyncStream.makeStream(of: String.self)
Этот новый подход особенно полезен в случаях, когда продолжение (continuation) требуется использовать вне текущего контекста, например, в другом методе. Давайте для примера создадим старым способом простой генератор чисел, который должен сохранять continuetion как своё собственное свойство, чтобы иметь возможность вызывать его из метода queueWork()
:
struct OldNumberGenerator {
private var continuation: AsyncStream.Continuation!
var stream: AsyncStream!
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
и stream
. continuation
это специальный объект, который позволяет добавить новые элементы в stream
или закончить его.
В конструкторе init()
создается stream
и continuation
. В блоке инициализации stream
свойство continuation
устанавливается в continuation, переданную в этот блок.