Что нового в Swift 5.7

Или, как я начал это называть, что не является новым в Swift 5.7?

Swift 5.7 привносит в язык очередную гигантскую коллекцию изменений и улучшений, включая такие мощные фичи, как регулярные выражения; практические усовершенствования, как сокращенный синтаксис if let; а также множество доработок по приведению в порядок согласованности вокруг ключевых слов any и some.

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

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

  • Я благодарен Холли Борла (Holly Borla) за то, что она нашла время, чтобы ответить на мои вопросы относительно новых предложений по дженерикам — если в них вкрались какие-либо ошибки, то это моя, а не ее неточность.

Сокращенная конструкция if let для распаковки опционалов

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

var name: String? = "Linda"

if let name {
    print("Hello, \(name)!")
}

Если раньше мы писали код примерно так:

if let name = name {
    print("Hello, \(name)!")
}

if let unwrappedName = name {
    print("Hello, \(unwrappedName)!")
}  

Это изменение не распространяется на свойства внутри объектов, что означает, что код, подобный этому, не будет работать:

struct User {
    var name: String
}

let user: User? = User(name: "Linda")

if let user.name {
    print("Welcome, \(user.name)!")
}

Вывод типа замыкания с несколькими операторами

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

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

let scores = [100, 80, 85]

let results = scores.map { score in
    if score >= 85 {
        return "\(score)%: Pass"
    } else {
        return "\(score)%: Fail"
    }
}

До Swift 5.7 нам нужно было явно указывать тип возврата, как здесь:

let oldResults = scores.map { score -> String in
    if score >= 85 {
        return "\(score)%: Pass"
    } else {
        return "\(score)%: Fail"
    }
}

Часы, мгновение и длительность

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

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

  • Мгновения представляют собой точный момент времени.

  • Длительность показывает, сколько времени прошло между двумя мгновениями.

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

try await Task.sleep(until: .now +  .seconds(1), clock: .continuous)

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

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

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

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

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

let clock = ContinuousClock()

let time = clock.measure {
    // complex work here
}

print("Took \(time.components.seconds) seconds")

Регулярные выражения

Swift 5.7 вводит целый ряд улучшений, касающихся регулярных выражений (regexes), значительно улучшая при этом способ обработки строк. Фактически здесь реализована целая цепочка взаимосвязанных предложений, в том числе

  • SE-0350 вводит новый тип Regex

  • SE-0351 вводит DSL для создания регулярных выражений на основе построителя результатов.

  • SE-0354 добавляет возможность создания регулярного выражения с помощью /.../, а не через Regex и строку.

  • SE-0357 добавляет множество новых алгоритмов обработки строк, основанных на регулярных выражениях.

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

Чтобы понять, что изменилось, давайте начнем с простого и станем двигаться дальше.

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

let message = "the cat sat on the mat"
print(message.ranges(of: "at"))
print(message.replacing("cat", with: "dog"))
print(message.trimmingPrefix("the "))

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

print(message.ranges(of: /[a-z]at/))
print(message.replacing(/[a-m]at/, with: "dog"))
print(message.trimmingPrefix(/The/.ignoresCase()))

В случае, если вы не знакомы с регулярными выражениями:

  • В первом регулярном выражении мы запрашиваем диапазон всех подстрок, которые соответствуют любой строчной букве алфавита, за которой следует "at", поэтому оно найдет местоположение "cat", "sat" и "mat".

  • Во втором случае мы ищем только диапазон от "a" до "m", поэтому будет выведено "the dog sat on the dog".

  • В третьем случае мы ищем слово "The", но я изменил regex так, чтобы он не учитывал регистр, поэтому он будет соответствовать "the", "THE" и так далее.

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

Наряду с регекс-литералами, Swift предоставляет специальный тип Regex, который работает аналогичным образом:

do {
    let atSearch = try Regex("[a-z]at")
    print(message.ranges(of: atSearch))
} catch {
    print("Failed to create regex")
}

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

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

Чтобы увидеть, насколько сильным является это различие, рассмотрим этот код:

let search1 = /My name is (.+?) and I'm (\d+) years old./
let greeting1 = "My name is Taylor and I'm 26 years old."

if let result = try search1.wholeMatch(in: greeting1) {
    print("Name: \(result.1)")
    print("Age: \(result.2)")
}

Это создает регекс, ищущий два определенных значения в некотором тексте, и если он их находит, то печатает оба. Но обратите внимание, что кортеж result может ссылаться на свои совпадения как .1 и .2, потому что Swift точно знает, какие из совпадений будут обнаружены. (Если вам интересно, .0 возвращает всю совпавшую строку).

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

let search2 = /My name is (?<name>.+?) and I'm (?<age>\d+) years old./
let greeting2 = "My name is Taylor and I'm 26 years old."

if let result = try search2.wholeMatch(in: greeting2) {
    print("Name: \(result.name)")
    print("Age: \(result.age)")
}

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

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

Например, если бы мы хотели сопоставить такой же текст "My name is Taylor and I'm 26 years old", то могли бы написать регекс следующим образом:

let search3 = Regex {
    "My name is "

    Capture {
        OneOrMore(.word)
    }

    " and I'm "

    Capture {
        OneOrMore(.digit)
    }

    " years old."
}

Еще лучше то, что этот DSL-подход способен преобразовывать найденные соответствия, и если мы используем TryCapture, а не Capture, то Swift будет автоматически считать весь регекс не соответствующим, если захват не удался или выбросит ошибку. Так, в случае с подбором возраста мы можем написать следующее, чтобы преобразовать строку возраста в целое число:

let search4 = Regex {
    "My name is "

    Capture {
        OneOrMore(.word)
    }

    " and I'm "

    TryCapture {
        OneOrMore(.digit)
    } transform: { match in
        Int(match)
    }

    Capture(.digit)

    " years old."
}

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

let nameRef = Reference(Substring.self)
let ageRef = Reference(Int.self)

let search5 = Regex {
    "My name is "

    Capture(as: nameRef) {
        OneOrMore(.word)
    }

    " and I'm "

    TryCapture(as: ageRef) {
        OneOrMore(.digit)
    } transform: { match in
        Int(match)
    }

    Capture(.digit)

    " years old."
}

if let result = greeting.firstMatch(of: search5) {
    print("Name: \(result[nameRef])")
    print("Age: \(result[ageRef])")
}

Из всех трех вариантов, я подозреваю, что регекс-литералы будут использоваться чаще всего, хотя похоже, их поддержка по умолчанию будет отключена до появления Swift 6 — добавьте "-Xfrontend -enable-bare-slash-regex" в настройку Other Swift Flags в Xcode, чтобы включить этот синтаксис.

Вывод типа из выражений по умолчанию

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

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

func drawLotto1<T: Sequence>(from options: T, count: Int = 7) -> [T.Element] {
    Array(options.shuffled().prefix(count))
}

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

print(drawLotto1(from: 1...49))
print(drawLotto1(from: ["Jenny", "Trixie", "Cynthia"], count: 2))

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

func drawLotto2<T: Sequence>(from options: T = 1...49, count: Int = 7) -> [T.Element] {
    Array(options.shuffled().prefix(count))
}

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

print(drawLotto2(from: ["Jenny", "Trixie", "Cynthia"], count: 2))
print(drawLotto2())

Параллелизм в коде верхнего уровня

SE-0343 обновляет поддержку Swift для кода верхнего уровня — представьте себе main.swift в проекте командной строки macOS — так, чтобы он поддерживал параллелизм из коробки. Это одно из тех изменений, которые могут показаться тривиальными на первый взгляд, но потребовалась большая работа, чтобы такое произошло.

На практике это означает, что вы можете писать подобный код прямо в файлах main.swift:

let url = URL(string: "https://hws.dev/readings.json")!
let (data, _) = try await URLSession.shared.data(from: url)
let readings = try JSONDecoder().decode([Double].self, from: data)
print("Found \(readings.count) temperature readings")

Ранее нам приходилось создавать новую структуру @main, которая содержала асинхронный метод main(), так что данный подход намного лучше.

Непрозрачные объявления параметров

SE-0341 открывает возможность использовать some с объявлениями параметров в местах, где использовались более простые дженерики.

Например, если мы хотим написать функцию, которая проверяет, отсортирован ли массив, Swift 5.7 и более поздние версии позволяют нам написать следующее:

func isSorted(array: [some Comparable]) -> Bool {
    array == array.sorted()
}

Тип параметра [some Comparable] означает — данная функция работает с массивом, содержащим элементы одного типа, который соответствует протоколу Comparable, что является синтаксическим сахаром для аналогичного generic-кода:

func isSortedOld<T: Comparable>(array: [T]) -> Bool {
    array == array.sorted()
}

Конечно, мы также можем написать еще более длинное ограниченное расширение:

extension Array where Element: Comparable {
    func isSorted() -> Bool {
        self == self.sorted()
    }
}

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

Важно: Вы можете переключаться между явными generic-параметрами и этим новым упрощенным синтаксисом, не ломая свой API.

Структурные непрозрачные типы результатов

SE-0328 расширяет диапазон мест, где можно использовать непрозрачные типы результатов.

Например, теперь мы можем возвращать более одного непрозрачного типа одновременно:

func showUserDetails() -> (some Equatable, some Equatable) {
    (Text("Username"), Text("@twostraws"))
}

Мы также можем возвращать непрозрачные типы:

func createUser() -> [some View] {
    let usernames = ["@frankefoster", "@mikaela__caron", "@museumshuffle"]
    return usernames.map(Text.init)
}

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

func createDiceRoll() -> () -> some View {
    return {
        let diceRoll = Int.random(in: 1...6)
        return Text(String(diceRoll))
    }
}

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

Разблокировка экзистенциальностей для всех протоколов

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

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

let firstName: any Equatable = "Paul"
let lastName: any Equatable = "Hudson"

Equatable — это протокол с Self-требованиями, что означает, что он обеспечивает функциональность, относящуюся к конкретному типу, который его принимает. Например, Int соответствует Equatable, поэтому, когда мы говорим 4 == 4, мы на самом деле запускаем функцию, которая принимает два целых числа и возвращает true, если они совпадают.

Swift мог бы реализовать эту возможность с помощью функции, подобной func ==(first: Int, second: Int) -> Bool, но это было бы не очень удобно — пришлось бы писать десятки таких функций для обработки булевых чисел, строк, массивов и так далее. Поэтому вместо этого протокол Equatable содержит следующее требование: func ==(lhs: Self, rhs: Self) -> Bool. В переводе с английского это означает "вы должны быть способны принять два экземпляра одного типа и сказать мне, одинаковы ли они". Это могут быть два целых числа, две строки, два булевых числа или два любых других типа, соответствующих Equatable.

Чтобы избежать этой и подобных ей проблем, в любое время, когда Self появлялся в протоколе до выхода Swift 5.7, компилятор просто не позволял нам использовать его в коде, подобном этому:

let tvShow: [any Equatable] = ["Brooklyn", 99]

Начиная со Swift 5.7, такой код стал разрешен, и теперь ограничения переносятся на ситуации, когда вы пытаетесь использовать тип в месте, где Swift должен их реально применить. Это означает, что мы не можем написать firstName == lastName, потому что, как я уже сказал, для работы == необходимо убедиться, что у него есть два экземпляра одного типа, а при использовании any Equatable мы скрываем точные типы наших данных.

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

for parts in tvShow {
    if let item = item as? String {
        print("Found string: \(item)")
    } else if let item = item as? Int {
        print("Found integer: \(item)")
    }
}

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

if let firstName = firstName as? String, let lastName = lastName as? String {
    print(firstName == lastName)
}

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

func canBeIdentified(_ input: any Sequence) -> Bool {
    input.allSatisfy { $0 is any Identifiable }
}

Облегченные требования однотипности для основных связанных типов

SE-0346 добавляет новый, более простой синтаксис для ссылок на протоколы, которые имеют определенные связанные типы.

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

protocol Cache<Content> {
    associatedtype Content

    var items: [Content] { get set }

    init(items: [Content])
    mutating func add(item: Content)
}

Обратите внимание, что протокол теперь похож и на протокол, и на generic-тип — он имеет связанный тип, объявляющий некую пустоту, которую должны заполнить соответствующие типы, и при этом указывает такой тип в угловых скобках: Cache<Content>.

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

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

struct File {
    let name: String
}

struct LocalFileCache: Cache {
    var items = [File]()

    mutating func add(item: File) {
        items.append(item)
    }
}

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

func loadDefaultCache() -> LocalFileCache {
    LocalFileCache(items: [])
}

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

func loadDefaultCacheOld() -> some Cache {
    LocalFileCache(items: [])
}

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

func loadDefaultCacheNew() -> some Cache<File> {
    LocalFileCache(items: [])
}

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

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

extension Cache<File> {
    func clean() {
        print("Deleting all cached files…")
    }
}

А также обобщенные (generic) ограничения:

func merge<C: Cache<File>>(_ lhs: C, _ rhs: C) -> C {
    print("Copying all files into a new location…")
    // now send back a new cache with items from both other caches
    return C(items: lhs.items + rhs.items)
}

Но самым полезным будет то, что SE-0358 перенесет эти основные связанные типы и в стандартную библиотеку Swift, так что Sequence, Collection и прочие будут в выигрыше — мы сможем писать Sequence<String> для создания кода, не зависящего от того, какой именно тип последовательности используется.

Ограниченные экзистенциальные типы

SE-0353 предоставляет возможность комбинировать SE-0309 ("Разблокировка экзистенциальностей для всех протоколов") и SE-0346 ("Облегченные требования однотипности для основных связанных типов") для написания кода наподобие any Sequence<String>.

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

Изоляция распределенного актора

SE-0336 и SE-0344 вводят возможность для акторов работать распределенно — читать и записывать свойства или вызывать методы по сети с помощью удаленных вызовов процедур (RPC).

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

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

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

  3. Чтобы перейти от актора к распределенному актору, нам обычно достаточно написать distributed actor, а затем distributed func по мере необходимости.

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

// use Apple's ClusterSystem transport 
typealias DefaultDistributedActorSystem = ClusterSystem

distributed actor CardCollector {
    var deck: Set<String>

    init(deck: Set<String>) {
        self.deck = deck
    }

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

        do {
            try await person.transfer(card: selected)
            deck.remove(selected)
            return true
        } catch {
            return false
        }
    }

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

Из-за характерного для распределенных акторов эффекта выбрасывания (throwing) вызовов, мы можем быть уверены в безопасности извлечения карты из одного коллектора, если вызов person.transfer(card:) не был выброшен.

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

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

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

И в-третьих, вам следует рассмотреть возможность настройки API вашего актора для минимизации запросов данных. Например, если вы хотите прочитать свойства username, firstName и lastName распределенного актора, вам лучше запросить все три свойства одним вызовом метода, а не запрашивать их по отдельности, чтобы избежать потенциальной необходимости несколько раз пересылать данные по сети.

buildPartialBlock для построителей результатов

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

Для наглядного примера вот упрощенная версия того, как выглядит ViewBuilder в SwiftUI:

@resultBuilder
struct SimpleViewBuilderOld {
    static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View {
        TupleView((c0, c1))
    }

    static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0: View, C1: View, C2: View {
        TupleView((c0, c1, c2))
    }
}

Я добавил две версии buildBlock(): одну, которая принимает два отображения, и другую, принимающую три. На практике SwiftUI принимает разнообразные альтернативы, но количественно только до 10 — есть вариант buildBlock(), который возвращает TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)>, но из практических соображений сверх этого ничего нет.

Затем мы можем использовать этот конструктор результатов с функциями или вычисляемыми свойствами, например, так:

@SimpleViewBuilderOld func createTextOld() -> some View {
    Text("1")
    Text("2")
    Text("3")
}

Это позволит принять все три отображения Text, используя вариант buildBlock<C0, C1, C2>(), и вернуть одно отображение TupleView, содержащее их все вместе. Однако в этом упрощенном примере нет возможности добавить четвертое отображение Text, потому что я не предусмотрел никаких дополнительных возможностей по перегрузке, точно так же, как SwiftUI не поддерживает 11 или более.

Здесь на помощь приходит новый метод buildPartialBlock(), который работает подобно reduce() для последовательностей: он имеет начальное значение, затем обновляет его, добавляя все, что у него уже есть, к тому, что будет дальше

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

@resultBuilder
struct SimpleViewBuilderNew {
    static func buildPartialBlock<Content>(first content: Content) -> Content where Content: View {
        content
    }

    static func buildPartialBlock<C0, C1>(accumulated: C0, next: C1) -> TupleView<(C0, C1)> where C0: View, C1: View {
        TupleView((accumulated, next))
    }
}

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

@SimpleViewBuilderNew func createTextNew() -> some View {
    Text("1")
    Text("2")
    Text("3")
}

Однако результат не является идентичным: в первом примере мы получили бы TupleView<Text, Text, Text>, тогда как теперь вернется  TupleView<(TupleView<(Text, Text)>, Text)> — один TupleView, вложенный в другой. К счастью, если команда SwiftUI действительно намерена принять это, они должны иметь возможность создать те же 10 перегрузок buildPartialBlock(), которые были у них раньше, что должно означать, что компиляция автоматически создает группы по 10 точно так же, как мы это делаем явно прямо сейчас.

Совет: buildPartialBlock() является частью Swift, а не каким-либо специфичным для платформы рантаймом, поэтому если вы примете его, то обнаружите, что он будет деплоиться на более ранние версии ОС.

Неявно открытые экзистенциалы

SE-0352 позволяет Swift вызывать generic-функции с использованием протокола во многих ситуациях, что устраняет весьма странный барьер, существовавший ранее.

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

func double<T: Numeric>(_ number: T) -> T {
    number * 2
}

Если мы вызываем эту функцию напрямую, например, double(5), то компилятор Swift может решить специализировать функцию — фактически создать версию, принимающую непосредственно Int, по соображениям производительности.

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

let first = 1
let second = 2.0
let third: Float = 3

let numbers: [any Numeric] = [first, second, third]

for number in numbers {
    print(double(number))
}

Swift называет это экзистенциальными типами: фактический тип данных, который вы используете, находится внутри блока ([ ]), и когда мы вызываем методы на нем, Swift понимает, что он должен неявно вызвать метод на данных внутри. SE-0352 распространяет эту же возможность и на вызовы функций: значение number в нашем цикле является экзистенциальным типом (блок, содержащий что-либо из Int, Double, или Float), но Swift способен передать его в generic-функцию double(), отправив значение внутри блока.

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

func areEqual<T: Numeric>(_ a: T, _ b: T) -> Bool {
    a == b
}

print(areEqual(numbers[0], numbers[1]))

Swift не может статически проверить (т.е. во время компиляции), что оба значения можно сравнить с помощью ==, поэтому код просто не будет собран.

Сниппеты Swift

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

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

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

  2. Их можно легко создавать и запускать из командной строки.

  3. Они прекрасно интегрируются в DocC и отображаются вместе с остальной документацией.

Специальная разметка бывает двух видов: вы можете использовать //! комментарии, чтобы создать краткое описание для каждого сниппета, и —// MARK: Hide и // MARK: Show для создания невидимых блоков кода, когда вам нужно сделать какую-то работу, не относящуюся конкретно к тому, что пытается продемонстрировать ваш сниппет.

Итак, мы можем создать такой сниппет:

//! Demonstrates how to use conditional conformance
//! to add protocols to a data type if it matches
//! some constraint.

struct Queue<Element> {
    private var array = [Element]() 
    // MARK: Hide

    mutating func append(_ element: Element) {
        array.append(element)
    }

    mutating func dequeue() -> Element? {
        guard array.count > 0 else { return nil }
        return array.remove(at: 0)
    }
    // MARK: Show
}

extension Queue: Encodable where Element: Encodable { }
extension Queue: Decodable where Element: Decodable { }
extension Queue: Equatable where Element: Equatable { }
extension Queue: Hashable where Element: Hashable { }

let queue1 = Queue<Int>()
let queue2 = Queue<Int>()
print(queue1 == queue2)

Здесь используются // MARK: Hide и // MARK: Show, чтобы скрыть некоторые детали реализации, позволяя читателям сосредоточиться на той части, которая важна.

Что касается поддержки командной строки, то теперь мы можем выполнить три новых варианта команд:

  • swift build --build-snippets для сборки всех ваших исходных целей, включая все ваши сниппеты # Собирает исходные цели, включая сниппеты.

  • swift build SomeSnippet для сборки SomeSnippet.swift как отдельного исполняемого файла.

  • swift run SomeSnippet для немедленного запуска SomeSnippet.swift.

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

Недоступность атрибута async

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

Чтобы пометить что-то как недоступное в контексте async, используйте @available с вашим обычным выбором платформ, а затем добавьте noasync в конце. Например, у нас может быть функция, которая работает на любой платформе, но может вызвать проблемы при асинхронном вызове, поэтому мы пометим ее следующим образом:

@available(*, noasync)
func doRiskyWork() {

}

Затем мы можем вызвать его из стандартной синхронной функции, как и всегда:

func synchronousCaller() {
    doRiskyWork()
}

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

func asynchronousCaller() async {
    doRiskyWork()
}

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

func sneakyCaller() async {
    synchronousCaller()
}

Это выполняется в контексте async, но вызывает синхронную функцию, которая, в свою очередь, может вызвать функцию noasync doRiskyWork().

Таким образом, noasync — это улучшение, но при его использовании все равно нужно быть осторожным. К счастью, как говорится в предложении Swift Evolution, "ожидается, что атрибут будет использоваться для довольно ограниченного набора специализированных случаев применения" — велика вероятность, что вы никогда не столкнетесь с кодом, в котором он используется.

Но подождите... это еще не все!

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

  • И многое другое!

Совершенно очевидно, что происходит огромное количество изменений, некоторые из которых фактически приведут к разрушению проектов. Поэтому, чтобы избежать больших сбоев, команда Swift решила отложить включение некоторых из них до выхода Swift 6 — все они уже есть, но вам, возможно, придется включить их с помощью флагов компилятора, таких как -enable-bare-slash-regex.

Что происходит?

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

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

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


Материал подготовлен в преддверии старта специализации iOS Developer.

Приглашаем всех желающих на открытое занятие «Flux в SwiftUI, самая эффективная архитектура на 2022 год?», на котором обсудим:
1. Очевидные проблемы MVVM при создании iOS приложений на SwiftUI;
2. Возможные расширения MVVM с помощью SOA и Coordinator паттернов;
3. Почему большинство приложений на SwiftUI пишется на архитектурной концепции Flux.
Регистирация доступна по ссылке для всех желающих.