Advanced Swift v5.6
Коллекции Link to heading
Коллекции элементов являются одними из самых важных типов данных в любом языке программирования. Хорошая поддержка различных видов контейнеров в языке программирования значительно влияет на продуктивность и удовлетворенность программиста. Swift уделяет особое внимание последовательностям и коллекциям — так много из стандартной библиотеки посвящено этой теме, что иногда создается впечатление, что она касается почти ничего другого. Полученная модель более расширяема, чем то, к чему вы, возможно, привыкли в других языках, но она также довольно сложна.
В этой главе мы рассмотрим основные типы коллекций, которые предоставляет Swift, с акцентом на то, как эффективно и идиоматично с ними работать. В главе о протоколах коллекций, которая будет позже в книге, мы поднимемся по лестнице абстракций и увидим, как работают протоколы коллекций в стандартной библиотеке.
Массивы Link to heading
Массивы — это самые распространенные коллекции в Swift. Массив — это упорядоченный контейнер элементов, которые все имеют один и тот же тип, и он предоставляет произвольный доступ к каждому элементу. В качестве примера, чтобы создать массив чисел, мы можем написать следующее:
// Числа Фибоначчи
let fibs = [0, 1, 1, 2, 3, 5]
Массивы и изменяемость Link to heading
Если мы попытаемся изменить массив, определенный выше (например, с помощью append(_:)), мы получим ошибку компиляции. Это происходит потому, что массив определен как константа с помощью let. Во многих случаях это именно то, что нужно; это предотвращает случайные изменения массива. Если мы хотим, чтобы массив был переменной, мы должны определить его с помощью var:
var mutableFibs = [0, 1, 1, 2, 3, 5]
Теперь мы можем легко добавить один элемент или последовательность элементов:
mutableFibs.append(8)
mutableFibs.append(contentsOf: [13, 21])
mutableFibs // [0, 1, 1, 2, 3, 5, 8, 13, 21]
Существует несколько преимуществ, связанных с различием между var и let. Константы, определенные с помощью let, легче анализировать, потому что они неизменяемы. Когда вы читаете объявление, такое как let fibs = ..., вы знаете, что значение fibs никогда не изменится — неизменяемость обеспечивается компилятором. Это значительно помогает при чтении кода. Однако обратите внимание, что это верно только для типов, имеющих семантику значений. Переменная let, содержащая ссылку на экземпляр класса, гарантирует, что ссылка никогда не изменится, т.е. вы не можете присвоить другой объект этой переменной. Однако объект, на который указывает ссылка, может измениться. Мы подробнее рассмотрим эти различия в главе о структурах и классах.
Массивы, как и все типы коллекций в стандартной библиотеке, имеют семантику значений. Когда вы присваиваете существующий массив другой переменной, содержимое массива копируется. Например, в следующем фрагменте кода x никогда не изменяется:
var x = [1, 2, 3]
var y = x
y.append(4)
y // [1, 2, 3, 4]
x // [1, 2, 3]
Утверждение var y = x создает копию x, поэтому добавление 4 в y не изменит x — значение x по-прежнему будет [1, 2, 3]. То же самое происходит, когда вы передаете массив в функцию; функция получает локальную копию массива, и любые изменения, которые она вносит, не влияют на вызывающий код.
Сравните это с подходом к изменяемости, принятым во многих других языках, таких как JavaScript, Java и Objective-C (используя NSArray из Foundation). Массивы в этих языках имеют семантику ссылок: изменение массива через одну переменную неявно изменяет то, что видят все другие переменные, ссылающиеся на тот же массив, потому что все они указывают на одно и то же хранилище. Вот пример на JavaScript:
//'const' делает *переменные* a и b неизменяемыми.
const a = [1, 2, 3];
const b = a;
//Но объект, на который они *ссылаются*, все еще изменяемый.
b.push(4);
console.log(b); // [1, 2, 3, 4]
console.log(a); // [1, 2, 3, 4]
Правильный способ записать это — вручную создать копию при присваивании:
const c = [1, 2, 3];
// Создаем явную копию.
const d = c.slice();
d.push(4);
console.log(d); // [1, 2, 3, 4]
console.log(c); // [1, 2, 3]
Забыть об этом легко и подвержено ошибкам. Например, объект, который возвращает массив из своего внутреннего состояния без создания копии, может внезапно нарушить свои инварианты, когда вызывающий код модифицирует массив. Swift избегает этой проблемы, предоставляя коллекциям семантику значений.
Создание копии при каждом присваивании может быть проблемой с производительностью, но на практике все типы коллекций в стандартной библиотеке Swift реализованы с использованием техники, называемой “копирование при записи” (copy-on-write), которая гарантирует, что данные копируются только при необходимости. Таким образом, в нашем примере x и y делили внутреннее хранилище до тех пор, пока не был вызван y.append. В главе о структурах и классах мы более подробно рассмотрим семантику значений — включая то, как реализовать “копирование при записи” для ваших собственных типов.
Индексация Массивов Link to heading
Массивы Swift предоставляют все обычные операции, которые вы ожидаете, такие как isEmpty и count. Массивы также позволяют прямой доступ к элементам по конкретному индексу через подстановку, как в array[3]. Имейте в виду, что вам нужно убедиться, что индекс находится в пределах допустимого диапазона, прежде чем получать элемент через подстановку. Получите элемент по индексу 3, и вы должны быть уверены, что массив содержит как минимум четыре элемента. В противном случае ваша программа завершится с ошибкой, т.е. аварийно завершится с фатальной ошибкой.
В Swift есть много способов работать с массивами, не прибегая к вычислению индекса:
- Хотите пройтись по массиву?
for x in array - Хотите пройтись по всем элементам, кроме первого?
for x in array.dropFirst() - Хотите пройтись по всем элементам, кроме последних пяти?
for x in array.dropLast(5) - Хотите пронумеровать все элементы в массиве?
for (num, element) in array.enumerated() - Хотите пройтись по индексам и элементам вместе?
for (index, element) in zip(array.indices, array) - Хотите найти местоположение конкретного элемента?
if let idx = array.firstIndex(where: { someMatchingLogic($0) }) - Хотите преобразовать все элементы в массиве?
array.map { someTransformation($0) } - Хотите получить только элементы, соответствующие определенному критерию?
array.filter { someCriteria($0) }
Еще один признак того, что Swift хочет отговорить вас от математических операций с индексами — это отсутствие традиционных циклов for в стиле C в языке. Ручное манипулирование индексами — это богатая почва для ошибок, поэтому это часто лучше избегать.
Но иногда вам действительно нужно использовать индекс. И с индексами массивов ожидается, что, когда вы это делаете, вы очень тщательно обдумали логику, стоящую за вычислением индекса. Поэтому необходимость разыменования значения операции подстановки, вероятно, является избыточной — это означает, что вы не доверяете своему коду. Но, скорее всего, вы доверяете своему коду, поэтому, вероятно, вы прибегнете к принудительному разыменованию результата, потому что знаете, что индекс должен быть действительным. Это (а) раздражает и (б) является плохой привычкой. Когда принудительное разыменование становится рутинным, в конечном итоге вы можете ошибиться и принудительно разыменовать что-то, что не собирались. Чтобы предотвратить эту привычку от становления рутинной, массивы не дают вам такой возможности.
Хотя операция подстановки, которая реагирует на недопустимый индекс контролируемым сбоем, может быть аргументирована как небезопасная, это только один аспект безопасности. Подстановка абсолютно безопасна с точки зрения безопасности памяти — коллекции стандартной библиотеки всегда выполняют проверки границ, чтобы предотвратить несанкционированный доступ к памяти с недопустимым индексом. В Swift термин “безопасность” обычно означает безопасность памяти и избегание неопределенного поведения.
Другие операции ведут себя по-другому. Свойства first и last возвращают опциональное значение, которое равно nil, если массив пуст. first эквивалентно isEmpty ? nil : self[0]. Аналогично, метод removeLast вызовет сбой, если вы вызовете его на пустом массиве, в то время как popLast просто удалит и вернет последний элемент, если массив не пуст, а в противном случае ничего не сделает и вернет nil. Какой из них вы хотите использовать, зависит от вашего случая использования. Когда вы используете массив как стек, вы, вероятно, всегда захотите объединить проверку на пустоту и удаление последнего элемента. С другой стороны, если вы уже знаете, пустой массив или нет, работа с опциональным значением может быть хлопотной.
Мы снова столкнемся с этими компромиссами позже в этой главе, когда будем говорить о словарях. Кроме того, есть целая глава, посвященная опциональным значениям.
Преобразование Массивов Link to heading
map Link to heading
Часто возникает необходимость выполнить преобразование для каждого значения в массиве. Каждый программист писал подобный код сотни раз: создать новый массив, пройтись по всем элементам существующего массива, выполнить операцию над элементом и добавить результат этой операции в новый массив. Например, следующий код возводит в квадрат массив целых чисел:
var squared: [Int] = []
for fib in fibs {
squared.append(fib * fib)
}
squared // [0, 1, 1, 4, 9, 25]
Массивы Swift имеют метод map, который был заимствован из мира функционального программирования. Вот то же самое действие с использованием map:
let squares = fibs.map { fib in fib * fib }
squares // [0, 1, 1, 4, 9, 25]
Эта версия имеет три основных преимущества. Во-первых, она короче, конечно. Во-вторых, в ней меньше места для ошибок. Но, что более важно, она яснее: весь беспорядок был убран. Как только вы привыкнете видеть и использовать map повсюду, это будет служить сигналом — вы видите map, и вы сразу понимаете, что происходит: функция будет применена к каждому элементу и вернет новый массив преобразованных элементов.
Объявление squares больше не нужно делать с помощью var, потому что мы не мутируем его — он будет возвращен из map полностью сформированным, так что мы можем объявить squares с помощью let, если это уместно. И поскольку тип содержимого может быть выведен из функции, переданной в map, squares больше не нужно явно типизировать.
Метод map несложно написать — это всего лишь вопрос обертывания стандартных частей цикла for в обобщенную функцию. Вот одна из возможных реализаций (хотя в Swift это на самом деле расширение протокола Sequence, который мы рассмотрим в главе о протоколах коллекций):
extension Array {
func map<T>(_ transform: (Element) -> T) -> [T] {
var result: [T] = []
result.reserveCapacity(count)
for x in self {
result.append(transform(x))
}
return result
}
}
Element — это обобщенное место для любого типа, который содержит массив, а T — это новое место, представляющее результат преобразования элемента. Функция map сама по себе не заботится о том, что такое Element и T; они могут быть чем угодно. Конкретный тип T преобразованных элементов определяется типом возвращаемого значения функции transform, которую вызывающий передает в map. См. главу о обобщениях для подробностей о обобщенных параметрах.
На самом деле, сигнатура этого метода должна быть:
func map<T>(_ transform: (Element) throws -> T) rethrows -> [T]
Это указывает на то, что map будет перенаправлять любую ошибку, которую может выбросить функция преобразования, вызывающему. Мы подробно рассмотрим это в главе об обработке ошибок, но здесь мы опустили аннотации обработки ошибок ради простоты. Если хотите, вы можете ознакомиться с исходным кодом для Sequence.map в репозитории Swift на GitHub.
Параметризация поведения с помощью функций Link to heading
Даже если вы уже знакомы с map, уделите минуту, чтобы рассмотреть реализацию map. Что делает её такой универсальной и в то же время полезной?map управляет отделением шаблонного кода — который не меняется от вызова к вызову — от функциональности, которая всегда варьируется, т.е. логики того, как именно преобразовать каждый элемент. Он делает это через параметр, который предоставляет вызывающий код: функцию преобразования.
Этот шаблон параметризации поведения встречается на протяжении всей стандартной библиотеки. Например, существует более дюжины отдельных методов в Array (и других типах коллекций), которые принимают функцию для настройки их поведения:
→ map и flatMap — преобразуют элементы
→ filter — включает только определённые элементы
→ allSatisfy — проверяет все элементы на соответствие условию
→ reduce — сворачивает элементы в агрегированное значение
→ forEach — посещает каждый элемент
→ sort(by:), sorted(by:), lexicographicallyPrecedes(_:by:) и partition(by:) — переупорядочивают элементы
→ firstIndex(where:), lastIndex(where:), first(where:), last(where:) и contains(where:) — существует ли элемент?
→ min(by:) и max(by:) — находят минимум или максимум среди всех элементов
→ elementsEqual(_:by:) и starts(with:by:) — сравнивают элементы с другим массивом
→ split(whereSeparator:) — разбивает элементы на несколько массивов
→ prefix(while:) — берёт элементы с начала, пока условие истинно
→ drop(while:) — отбрасывает элементы, пока условие истинно, а затем возвращает оставшиеся (аналогично prefix, но возвращает обратное)
→ removeAll(where:) — удаляет элементы, соответствующие условию
Цель всех этих функций — избавиться от загромождения неинтересных частей кода, таких как создание нового массива и цикл for по исходным данным. Вместо этого загромождение заменяется одним словом, которое описывает, что делается. Это выдвигает важный код — логику, которую программист хочет выразить — на передний план.
Несколько из этих функций имеют поведение по умолчанию. sort сортирует элементы в порядке возрастания, когда они сравнимы, если не указано иное, а contains может принимать значение для проверки, при условии, что элементы сравнимы. Эти значения по умолчанию помогают сделать код ещё более читаемым. Сортировка по возрастанию естественна, поэтому значение array.sort() интуитивно понятно, а array.firstIndex(of:"foo") яснее, чем array.firstIndex{$0=="foo"}.
Но в каждом случае это просто сокращение для общих случаев. Элементы не обязательно должны быть сравнимыми или эквивалентными, и вам не нужно сравнивать весь элемент — вы можете отсортировать массив людей по их возрасту (people.sort{$0.age<$1.age}) или проверить, содержит ли массив кого-либо несовершеннолетнего (people.contains{$0.age<18}). Вы также можете сравнивать некоторые преобразования элемента. Например, можно выполнить, хотя и неэффективную, сортировку без учета регистра с помощьюpeople.sort{$0.name.uppercased()<$1.name.uppercased()}.
Существуют и другие функции аналогичной полезности, которые также принимают функцию для указания их поведения, но которые не входят в стандартную библиотеку. Вы можете легко определить их сами (и, возможно, захотите попробовать):
→ accumulate — объединяет элементы в массив текущих значений (как reduce, но возвращает массив каждого промежуточного сочетания)
→ count(where:) — подсчитывает количество элементов, которые соответствуют условию (это должно было быть добавлено в Swift 5.0, но было отложено из-за конфликта имен с свойством count; надеемся, что оно будет повторно введено в следующем релизе)
→ indices(where:) — возвращает список индексов, соответствующих условию (аналогично firstIndex(where:), но не останавливается на первом)
Если вы обнаружите, что итерируете по массиву, чтобы выполнить ту же задачу или аналогичную более чем пару раз в вашем коде, подумайте о том, чтобы написать короткое расширение для Array.
Например, следующий код разбивает массив на группы смежных равных элементов:
let array: [Int] = [1, 2, 2, 2, 3, 4, 4]
var result: [[Int]] = array.isEmpty ? [] : [[array[0]]]
for (previous, current) in zip(array, array.dropFirst()) {
if previous == current {
result[result.endIndex-1].append(current)
} else {
result.append([current])
}
}
result // [[1], [2, 2, 2], [3], [4, 4]]
Мы можем формализовать этот алгоритм, абстрагируя код, который проходит по массиву парами смежных элементов, от логики, которая варьируется между приложениями (решение, где разбить массив). Мы используем аргумент функции, чтобы позволить вызывающему коду настроить последнее:
extension Array {
func split(where condition: (Element, Element) -> Bool) -> [[Element]] {
var result: [[Element]] = self.isEmpty ? [] : [[self[0]]]
for (previous, current) in zip(self, self.dropFirst()) {
if condition(previous, current) {
result.append([current])
} else {
result[result.endIndex-1].append(current)
}
}
return result
}
}
Это позволяет нам заменить цикл for на следующее:
let parts = array.split { $0 != $1 }
parts // [[1], [2, 2, 2], [3], [4, 4]]
Или, в случае этого конкретного условия, мы можем даже написать:
let parts2 = array.split(where: !=)
Это имеет все те же преимущества, которые мы описали для map. Пример с split(where:) более читаем, чем пример с циклом for; хотя цикл for прост, вам всё равно нужно прокрутить цикл в голове, что является небольшой умственной нагрузкой. Использование split(where:) снижает вероятность ошибки (например, случайно забыв о случае, когда массив пуст), и позволяет вам объявить переменную результата с помощью let, а не var.
Операция split(where:) также является частью пакета SwiftAlgorithms от Apple под названием chunked(by:). SwiftAlgorithms — это библиотека с открытым исходным кодом, содержащая часто используемые операции над коллекциями с высококачественными реализациями. Она предназначена как место с низким уровнем трения для сообщества Swift, чтобы итеративно работать над различными алгоритмами, не проходя сразу высокую планку для добавления в стандартную библиотеку. Функциональность, которая оказывается полезной, может затем мигрировать в стандартную библиотеку позже.
Мы расскажем больше о расширении коллекций и использовании функций позже в книге.
Мутации и состояния замыканий Link to heading
При итерации по массиву вы можете использовать map для выполнения побочных эффектов (например, вставки элементов в некоторую таблицу поиска). Мы не рекомендуем делать это. Посмотрите на следующее:
array.map { item in
table.insert(item)
}
Это скрывает побочный эффект (мутацию таблицы поиска) в конструкции, которая выглядит как преобразование массива. Если вы когда-либо увидите что-то подобное, это явный случай для использования обычного цикла for вместо функции, такой как map. Метод forEach также будет более уместен, чем map в этом случае, но у него есть свои проблемы, поэтому мы рассмотрим forEach немного позже.
Выполнение побочных эффектов отличается от преднамеренного предоставления замыканию локального состояния, что является особенно полезной техникой. В дополнение к своей полезности, это то, что делает замыкания — функции, которые могут захватывать и изменять переменные вне их области видимости — таким мощным инструментом в сочетании с функциями высшего порядка. Например, функция accumulate, описанная выше, может быть реализована с помощью map и состояния замыкания, следующим образом:
extension Array {
func accumulate<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Element) -> Result) -> [Result] {
var running = initialResult
return map { next in
running = nextPartialResult(running, next)
return running
}
}
}
Это создает временную переменную для хранения текущего значения и затем использует map для создания массива текущих значений по мере прогресса вычисления:
[1, 2, 3, 4].accumulate(0, +) // [1, 3, 6, 10]
filter Link to heading
Еще одной распространенной операцией является создание нового массива, который включает только те элементы, которые соответствуют определенному условию. Шаблон перебора массива и выбора элементов, соответствующих заданному предикату, зафиксирован в методе filter:
let nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
nums.filter { num in num % 2 == 0 } // [2, 4, 6, 8, 10]
Мы можем использовать сокращенную нотацию Swift для аргументов выражения замыкания, чтобы сделать это еще короче. Вместо того чтобы называть аргумент num, мы можем записать приведенный выше код так:
nums.filter { $0 % 2 == 0 } // [2, 4, 6, 8, 10]
Для очень коротких замыканий это может быть более читаемо. Если замыкание более сложное, почти всегда лучше явно называть аргументы, как мы делали ранее. Это действительно вопрос личного вкуса — выбирайте тот вариант, который более читаем на первый взгляд. Хорошее правило: если замыкание помещается аккуратно в одну строку, сокращенные имена аргументов подходят.
Объединив map и filter, мы можем записать множество операций над массивами, не вводя ни одной промежуточной переменной. Получившийся код станет короче и легче для чтения. Например, чтобы найти все квадраты чисел меньше 100, которые четные, мы можем применить map к диапазону 1..<10, чтобы возвести его члены в квадрат, а затем отфильтровать все нечетные числа:
(1..<10).map { $0 * $0 }.filter { $0 % 2 == 0 } // [4, 16, 36, 64]
Реализация filter выглядит аналогично map:
extension Array {
func filter(_ isIncluded: (Element) -> Bool) -> [Element] {
var result: [Element] = []
for x in self where isIncluded(x) {
result.append(x)
}
return result
}
}
Два быстрых совета по производительности: во-первых, обратите внимание, что цепочка map и filter таким образом создает промежуточный массив (результат операции map), который затем выбрасывается. Это не проблема для нашего небольшого примера, но для больших коллекций или длинных цепочек дополнительные аллокации могут негативно сказаться на производительности. Мы избегаем этих промежуточных массивов, вставив .lazy в начало цепочки, тем самым делая все преобразования ленивыми. Только в конце мы можем преобразовать ленивую коллекцию обратно в обычный массив:
let lazyFilter = (1..<10).lazy.map { $0 * $0 }.filter { $0 % 2 == 0 }
let filtered = Array(lazyFilter) // [4, 16, 36, 64]
Мы поговорим больше о ленивых последовательностях в главе о протоколах коллекций.
Во-вторых, если вы когда-либо окажетесь в ситуации, когда пишете что-то вроде следующего, остановитесь!
bigArray.filter { someCondition }.count > 0
filter создает совершенно новый массив и обрабатывает каждый элемент в массиве. Но это не обязательно. Этот код только должен проверить, соответствует ли один элемент условию — в этом случае contains(where:) выполнит задачу:
bigArray.contains { someCondition }
Это гораздо быстрее по двум причинам: он не создает новый массив отфильтрованных элементов только для того, чтобы посчитать их, и он завершает выполнение раньше — как только находит первое совпадение. В общем, используйте filter только если вам нужны все результаты.
reduce Link to heading
Обе функции map и filter принимают массив и производят новый, модифицированный массив. Однако иногда вы можете захотеть объединить все элементы в одно новое значение. Например, чтобы суммировать все элементы, мы могли бы написать следующий код:
let fibs = [0, 1, 1, 2, 3, 5]
var total = 0
for num in fibs {
total = total + num
}
total // 12
Метод reduce берет этот шаблон и абстрагирует две части: начальное значение (в данном случае, ноль) и функцию для объединения промежуточного значения (total) и элемента (num). Используя reduce, мы можем записать тот же пример так:
let sum = fibs.reduce(0) { total, num in total + num } // 12
Операторы тоже являются функциями, поэтому мы могли бы также записать тот же пример так:
fibs.reduce(0, +) // 12
Тип выходного значения reduce не обязательно должен совпадать с типом элемента. Например, если мы хотим преобразовать список целых чисел в строку, где каждое число будет следовать за запятой и пробелом, мы можем сделать следующее:
fibs.reduce("") { str, num in str + "\(num), " } // 0, 1, 1, 2, 3, 5,
Вот реализация для reduce:
extension Array {
func reduce<Result>(_ initialResult: Result,
_ nextPartialResult: (Result, Element) -> Result) -> Result {
var result = initialResult
for x in self {
result = nextPartialResult(result, x)
}
return result
}
}
Еще один совет по производительности: reduce очень гибок, и его часто используют для построения массивов и выполнения других операций. Например, вы можете реализовать map и filter, используя только reduce:
extension Array {
func map2<T>(_ transform: (Element) -> T) -> [T] {
return reduce([]) {
$0 + [transform($1)]
}
}
func filter2(_ isIncluded: (Element) -> Bool) -> [Element] {
return reduce([]) {
isIncluded($1) ? $0 + [$1] : $0
}
}
}
Это довольно красиво и имеет преимущество в том, что не требует этих надоедливых императивных циклов for. Но Swift не Haskell, и массивы Swift не являются списками. Что происходит здесь, так это то, что при каждом выполнении функции объединения создается совершенно новый массив, добавляя преобразованный или включенный элемент к предыдущему. Это означает, что обе эти реализации имеют сложность O(n²), а не O(n) — по мере увеличения длины массива время, необходимое для выполнения этих функций, увеличивается квадратично.
Существует вторая версия reduce, которая имеет немного другой тип. Функция редуктора для объединения промежуточного результата и элемента принимает Result в качестве параметра inout:
public func reduce<Result>(into initialResult: Result,
_ updateAccumulatingResult:
(_ partialResult: inout Result, Element) throws -> ()) rethrows -> Result
Мы подробно обсудим параметры inout в главах о функциях, структурах и классах, но на данный момент подумайте о параметре inout Result как о изменяемом параметре: мы можем модифицировать его внутри функции. Это позволяет нам написать filter гораздо более эффективно:
extension Array {
func filter3(_ isIncluded: (Element) -> Bool) -> [Element] {
return reduce(into: []) { result, element in
if isIncluded(element) {
result.append(element)
}
}
}
}
При использовании inout компилятор не должен создавать новый массив каждый раз, поэтому эта версия filter снова имеет сложность O(n). Когда вызов reduce(into:_:) инлайнится компилятором, сгенерированный код часто оказывается таким же, как при использовании цикла for.
A Flattening map Link to heading
Иногда вам нужно сопоставить массив, где функция преобразования возвращает другой массив, а не единственный элемент. Например, предположим, что у нас есть функция extractLinks, которая принимает текст в формате Markdown и возвращает массив, содержащий URL всех ссылок в тексте. Подпись функции выглядит следующим образом:
func extractLinks(markdown: String) -> [URL]
Если у нас есть несколько файлов Markdown и мы хотим извлечь ссылки из всех файлов в один массив, мы могли бы попробовать написать что-то вроде markdownFiles.map(extractLinks). Но это вернет массив массивов, содержащих URL: один массив на файл. Теперь вы могли бы просто выполнить map, получить массив массивов, а затем вызвать joined, чтобы объединить результаты в один массив:
let markdownFiles: [String] = // ...
let nestedLinks = markdownFiles.map(extractLinks)
let links = nestedLinks.joined()
Метод flatMap объединяет эти две операции — сопоставление и уплощение — в один шаг. Таким образом, markdownFiles.flatMap(extractLinks) возвращает все URL в массиве файлов Markdown в виде одного массива.
Подпись для flatMap почти идентична map, за исключением того, что функция преобразования возвращает массив. Реализация использует append(contentsOf:) вместо append(_:), чтобы уплотнить результирующий массив:
extension Array {
func flatMap<T>(_ transform: (Element) -> [T]) -> [T] {
var result: [T] = []
for x in self {
result.append(contentsOf: transform(x))
}
return result
}
}
Еще один отличный случай использования flatMap — это объединение элементов из разных массивов. Чтобы получить все возможные пары элементов из двух массивов, используйте flatMap по одному массиву, а затем map по другому в внутренней функции преобразования:
let suits = [" ♠ ", " ♥ ", " ♣ ", " ♦ "]
let ranks = ["J", "Q", "K", "A"]
let result = suits.flatMap { suit in
ranks.map { rank in
(suit, rank)
}
}
/*
[(" ♠ ", "J"), (" ♠ ", "Q"), (" ♠ ", "K"), (" ♠ ", "A"), (" ♥ ", "J"), (" ♥ ",
"Q"), (" ♥ ", "K"), (" ♥ ", "A"), (" ♣ ", "J"), (" ♣ ", "Q"), (" ♣ ", "K"),
(" ♣ ", "A"), (" ♦ ", "J"), (" ♦ ", "Q"), (" ♦ ", "K"), (" ♦ ", "A")]
*/
Итерация с использованием forEach Link to heading
Последняя операция, которую мы хотели бы обсудить, — это forEach. Она работает почти как цикл for: переданная функция выполняется один раз для каждого элемента в последовательности. В отличие от map, forEach ничего не возвращает — она специально предназначена для выполнения побочных эффектов. Давайте начнем с механической замены цикла на forEach:
for element in [1, 2, 3] {
print(element)
}
[1, 2, 3].forEach { element in
print(element)
}
Это незначительное улучшение, но оно может быть полезным, если действие, которое вы хотите выполнить, — это единственный вызов функции для каждого элемента в коллекции. Передача имени функции в forEach вместо передачи выражения замыкания может привести к более ясному и лаконичному коду. Например, если вы пишете контроллер представления в iOS и хотите добавить массив подвидов в главное представление, вы можете просто использовать theViews.forEach(view.addSubview).
Однако есть некоторые тонкие различия между циклами for и forEach. Например, если в цикле for есть оператор return, переписывание его с использованием forEach может значительно изменить поведение кода. Рассмотрим следующий пример, который написан с использованием цикла for с условием where:
extension Array where Element: Equatable {
func firstIndex(of element: Element) -> Int? {
for idx in self.indices where self[idx] == element {
return idx
}
return nil
}
}
Мы не можем напрямую воспроизвести условие where в конструкции forEach, поэтому мы можем (неправильно) переписать это с использованием filter:
extension Array where Element: Equatable {
func firstIndex_foreach(of element: Element) -> Int? {
self.indices.filter { idx in
self[idx] == element
}.forEach { idx in
return idx
}
return nil
}
}
Оператор return внутри замыкания forEach не возвращает из внешней функции; он возвращает только из самого замыкания. В данном конкретном случае мы, вероятно, нашли бы ошибку, потому что компилятор генерирует предупреждение о том, что аргумент для оператора return не используется, но не стоит полагаться на то, что он найдет каждую такую проблему.
Также рассмотрим следующий пример:
(1..<10).forEach { number in
print(number)
if number > 2 { return }
}
Не сразу очевидно, что это выводит все числа в заданном диапазоне. Оператор return не прерывает цикл; скорее, он возвращает из замыкания, тем самым начиная новую итерацию цикла внутри реализации forEach.
В некоторых ситуациях, таких как пример с addSubview выше, forEach может быть удобнее, чем цикл for. Однако из-за неочевидного поведения оператора return мы рекомендуем избегать большинства других случаев использования forEach. Просто используйте обычный цикл for вместо этого.
Array Slices Link to heading
В дополнение к доступу к отдельному элементу массива по индексу (например, fibs[0]), мы также можем получить диапазон элементов по индексу. Например, чтобы получить все элементы массива, кроме первого, мы можем сделать следующее:
let slice = fibs[1...]
slice // [1, 1, 2, 3, 5]
type(of: slice) // ArraySlice<Int>
Это дает нам срез массива, начиная со второго элемента. Тип результата — ArraySlice, а не Array. ArraySlice является представлением массивов. Он основан на оригинальном массиве, но предоставляет представление только на срез. В результате создание среза является дешевым — элементы массива не копируются.
Тип ArraySlice имеет те же методы, что и Array (поскольку оба соответствуют одним и тем же протоколам, наиболее важно — Collection), поэтому вы можете использовать срез так, как если бы это был массив. Если вам нужно преобразовать срез в массив, вы можете просто создать новый массив из среза:
let newArray = Array(slice)
type(of: newArray) // Array<Int>
Важно помнить, что срезы всегда используют те же индексы для обращения к конкретному элементу, что и их базовая коллекция. В результате индексы срезов не обязательно начинаются с нуля. Например, первый элемент среза (fibs[1…] , который мы создали выше, находится по индексу 1, и случайный доступ к slice[0] приведет к сбою нашей программы с нарушением границ. Если вы работаете с индексами, мы рекомендуем всегда основывать ваши вычисления на свойствах startIndex и endIndex, даже если вы имеете дело с обычным массивом, где 0 и count-1 также сработают. Это слишком легко для этого неявного предположения сломаться позже. Мы подробно обсудим это свойство срезов в главе о протоколах коллекций.
Dictionaries Link to heading
Другой ключевой структурой данных является Словарь. Словарь содержит уникальные ключи с соответствующими значениями. Извлечение значения по его ключу занимает постоянное время в среднем, в то время как поиск элемента в массиве растет линейно с увеличением размера массива. В отличие от массивов, словари неупорядочены; порядок, в котором пары ключ-значение перечисляются в цикле for, не определен.
В следующем примере мы используем словарь в качестве модели данных для экрана настроек в приложении для смартфона. Экран состоит из списка настроек, и каждая отдельная настройка имеет имя (ключи в нашем словаре) и значение. Значение может быть одним из нескольких типов данных, таких как текст, числа или логические значения. Мы используем перечисление с ассоциированными значениями для моделирования этого:
enum Setting {
case text(String)
case int(Int)
case bool(Bool)
}
let defaultSettings: [String: Setting] = [
"Режим самолета": .bool(false),
"Имя": .text("Мой iPhone"),
]
defaultSettings["Имя"] // Optional(Setting.text("Мой iPhone"))
Мы используем сабскрипт для получения значения настройки. Поиск в словаре всегда возвращает опциональное значение — когда указанный ключ не существует, он возвращает nil. Сравните это с массивами, которые реагируют на выход за пределы массива, вызывая сбой программы.
Словари также имеют сабскрипт, который принимает индекс (в отличие от обычно используемого сабскрипта, который принимает ключ) в рамках их соответствия протоколу Collection. Этот сабскрипт вызывает ошибку, когда вызывается с недопустимым индексом, так же как и сабскрипт массива, но он почти никогда не используется (за исключением неявного использования в обобщенных алгоритмах коллекций).
Обоснование этой разницы заключается в том, что индексы массивов и ключи словарей используются очень по-разному. Мы уже видели, что довольно редко вам действительно нужно работать с индексами массивов напрямую. И если вы это делаете, индекс массива обычно прямо выводится из массива каким-либо образом (например, из диапазона, такого как 0..<array.count); таким образом, использование недопустимого индекса является ошибкой программиста. С другой стороны, очень часто ключи словаря приходят из какого-то источника, отличного от самого словаря, который индексируется.
В отличие от массивов, словари также разреженные. Наличие значения под ключом “name” не говорит вам ничего о том, существует ли ключ “address”.
Mutating Dictionaries Link to heading
Так же как и массивы, словари, определенные с помощью let, являются неизменяемыми: в них нельзя добавлять, удалять или изменять записи. И так же, как и с массивами, мы можем определить изменяемый вариант, используя var. Чтобы удалить значение из словаря, мы можем либо установить его в nil, используя подстановку, либо вызвать метод removeValue(forKey:). Последний также возвращает либо удаленное значение, либо nil, если ключа не существовало. Если мы хотим взять неизменяемый словарь и внести в него изменения, нам нужно сделать его копию:
var userSettings = defaultSettings
userSettings["Name"] = .text("iPhone Джареда")
userSettings["Не беспокоить"] = .bool( true )
Обратите внимание, что значение defaultSettings снова не изменилось. Как и при удалении ключа, альтернативой обновлению через подстановку является метод updateValue(_:forKey:), который возвращает предыдущее значение (если таковое имеется):
let oldName = userSettings.updateValue(.text("iPhone Джейн"), forKey: "Name")
userSettings["Name"] // Optional(Setting.text("iPhone Джейн"))
oldName // Optional(Setting.text("iPhone Джареда"))
Некоторые полезные методы Dictionary Link to heading
Что если мы захотим объединить словарь настроек по умолчанию с любыми пользовательскими настройками, которые изменил пользователь? Пользовательские настройки должны переопределять значения по умолчанию, но результирующий словарь все равно должен включать значения по умолчанию для любых ключей, которые не были настроены. По сути, мы хотим объединить два словаря, где словарь, который объединяется, перезаписывает дублирующиеся ключи.
Словарь имеет метод merge(_:uniquingKeysWith:), который принимает пары ключ-значение для объединения и функцию, которая определяет, как комбинировать два значения с одинаковым ключом. Мы можем использовать это для объединения одного словаря в другой, как показано в следующем примере:
var settings = defaultSettings
let overriddenSettings: [String: Setting] = ["Name": .text("iPhone Джейн")]
settings.merge(overriddenSettings, uniquingKeysWith: { $1 })
settings
// ["Name": Setting.text("iPhone Джейн"), "Режим самолета": Setting.bool(false)]
В приведенном выше примере мы использовали { $1 } в качестве политики для комбинирования двух значений. Другими словами, если ключ существует как в settings, так и в overriddenSettings, мы используем значение из overriddenSettings.
Мы также можем создать новый словарь из последовательности пар (Ключ, Значение). Если мы гарантируем, что ключи уникальны, мы можем использовать Dictionary(uniqueKeysWithValues:). Однако, если у нас есть последовательность, где ключ может существовать несколько раз, нам нужно предоставить функцию для комбинирования двух значений для одинаковых ключей, как и выше. Например, чтобы вычислить, как часто элементы появляются в последовательности, мы можем сопоставить каждый элемент, объединить его с 1, а затем создать словарь из получившихся пар элемент-частота. Если мы сталкиваемся с двумя значениями для одного и того же ключа (другими словами, если мы видим один и тот же элемент более одного раза), мы просто складываем частоты, используя +:
extension Sequence where Element: Hashable {
var frequencies: [Element: Int] {
let frequencyPairs = self.map { ($0, 1) }
return Dictionary(frequencyPairs, uniquingKeysWith: +)
}
}
let frequencies = "hello".frequencies // ["e": 1, "h": 1, "l": 2, "o": 1]
frequencies.filter { $0.value > 1 } // ["l": 2]
Еще один полезный метод — это применение map к значениям словаря. Поскольку Dictionary является последовательностью, у него уже есть метод map, который производит массив. Однако иногда мы хотим сохранить структуру словаря нетронутой и только преобразовать его значения. Метод mapValues делает именно это:
let settingsAsStrings = settings.mapValues { setting -> String in
switch setting {
case .text(let text): return text
case .int(let number): return String(number)
case .bool(let value): return String(value)
}
}
settingsAsStrings // ["Name": "iPhone Джейн", "Режим самолета": "false"]
Hashable Requirement for Keys Link to heading
Словари являются хеш-таблицами. Словарь назначает каждой ключевой позиции в своем базовом массиве хранения на основе хеш-значения ключа. Вот почему словарь требует, чтобы его тип ключа соответствовал протоколу Hashable. Все базовые типы данных в стандартной библиотеке — включая строки, целые числа, числа с плавающей запятой и логические значения — уже соответствуют этому протоколу. Кроме того, многие другие типы — такие как массивы, множества и опционалы — автоматически становятся хешируемыми, если их элементы хешируемы.
Чтобы поддерживать свои гарантии производительности, хеш-таблицы требуют, чтобы типы, хранящиеся в них, предоставляли хорошую хеш-функцию, которая не производит слишком много коллизий. Написать хорошую хеш-функцию, которая равномерно распределяет свои входные данные по всему диапазону целых чисел, не так просто. К счастью, нам почти никогда не нужно делать это самостоятельно. Компилятор может сгенерировать соответствие Hashable во многих случаях, и даже если это не сработает для конкретного типа, стандартная библиотека поставляется с встроенной хеш-функцией, к которой могут подключаться пользовательские типы.
Для структур и перечислений Swift может автоматически синтезировать соответствие Hashable для нас, если эти типы сами состоят из хешируемых типов. Если все хранимые свойства структуры хешируемы, то сама структура может быть приведена к Hashable без необходимости писать ручную реализацию. Аналогично, перечисления, которые содержат только хешируемые ассоциированные значения, могут быть приведены к Hashable бесплатно (перечисления без ассоциированных значений даже соответствуют Hashable без явного объявления этого соответствия). Это не только экономит время на начальную реализацию, но и автоматически поддерживает реализацию в актуальном состоянии по мере добавления или удаления свойств.
Если вы не можете воспользоваться автоматической синтезой Hashable (потому что вы пишете класс или ваш пользовательский тип имеет одно или несколько хранимых свойств, которые должны быть проигнорированы для целей хеширования), вам сначала нужно сделать тип Equatable. Затем вы можете реализовать требование hash(into:) протокола Hashable. Этот метод принимает Hasher, который оборачивает универсальную хеш-функцию и захватывает состояние хеш-функции, когда клиенты передают данные в нее. У Hasher есть метод combine, который принимает любое хешируемое значение. Вы должны передать все основные компоненты вашего типа в hasher, передавая их в combine по одному. Основные компоненты — это свойства, которые составляют суть типа; вы обычно захотите исключить временные свойства, которые могут быть воссозданы лениво или которые не видны пользователям типа. Например, Array хранит емкость своего буфера — т.е. максимальное количество элементов, которое он может хранить, прежде чем потребуется перераспределение — внутренне. Но два массива, которые отличаются только емкостью, должны сравниваться и хешироваться одинаково — емкость не является основным компонентом типа Array.
Вы должны использовать одни и те же основные компоненты для проверки равенства, потому что должно выполняться следующее важное инвариант: два экземпляра, которые равны (как определено вашей реализацией ==), должны иметь одинаковое хеш-значение. Обратное не верно: два экземпляра с одинаковым хеш-значением не обязательно сравниваются как равные. Это имеет смысл, учитывая, что существует лишь конечное количество различных хеш-значений, в то время как многие хешируемые типы (например, строки) имеют по сути бесконечную кардинальность.
Универсальная хеш-функция стандартной библиотеки использует случайное семя в качестве одного из своих входов. Другими словами, хеш-значение, скажем, строки “abc” будет отличаться при каждом выполнении программы. Случайное семя является мерой безопасности для защиты от целевых атак типа “отказ в обслуживании” с использованием хеш-флудинга. Поскольку Dictionary и Set итерируют по своим элементам в порядке, в котором они хранятся в хеш-таблице, и поскольку этот порядок определяется хеш-значениями, это означает, что один и тот же код будет производить разные порядки итерации при каждом запуске. Если вам нужно детерминированное хеширование (например, для тестов), вы можете отключить случайное семя, установив переменную окружения SWIFT_DETERMINISTIC_HASHING=1, но не следует делать это в производственной среде.
Наконец, будьте особенно осторожны, когда используете типы, которые не имеют семантики значений (например, изменяемые объекты) в качестве ключей словаря. Если вы измените объект после использования его в качестве ключа словаря таким образом, что изменится его хеш-значение и/или равенство, вы не сможете снова найти его в словаре. Теперь словарь хранит объект в неправильном слоте, фактически повреждая его внутреннее хранилище. Это не проблема для типов значений, потому что ключ в словаре не разделяет хранилище вашей копии и, следовательно, не может быть изменен извне.
Sets Link to heading
Третьим основным типом коллекций в стандартной библиотеке является Множество (Set). Множество — это неупорядоченная коллекция элементов, где каждый элемент появляется только один раз. Вы можете в основном рассматривать множество как словарь, который хранит только ключи и не имеет значений. Как и Словарь (Dictionary), Множество реализовано с помощью хеш-таблицы и имеет аналогичные характеристики производительности и требования. Проверка значения на принадлежность множеству — это операция постоянного времени, и элементы множества должны быть хешируемыми, так же как и ключи словаря.
Используйте множество вместо массива, когда вам нужно эффективно проверять принадлежность (операция O(n) для массивов), и порядок элементов не важен, или когда вам нужно гарантировать, что коллекция не содержит дубликатов.
Множество соответствует протоколу ExpressibleByArrayLiteral, что означает, что мы можем инициализировать его с помощью литерала массива, как показано ниже:
let naturals: Set = [1, 2, 3, 2]
naturals // [1, 2, 3]
naturals.contains(3) // true
naturals.contains(0) // false
Обратите внимание, что число 2 появляется в множестве только один раз; дубликат даже не добавляется.
Как и все коллекции, множества поддерживают общие операции, которые мы уже видели: вы можете перебирать элементы в цикле for, применять к ним map или filter и выполнять множество других операций.
Set Algebra Link to heading
Как следует из названия, Set тесно связан с математической концепцией множества; он поддерживает все общие операции над множествами, которые вы изучали на уроках математики. Например, мы можем вычесть одно множество из другого:
let iPods: Set = ["iPod touch", "iPod nano", "iPod mini", "iPod shuffle", "iPod classic"]
let discontinuedIPods: Set = ["iPod mini", "iPod classic", "iPod nano", "iPod shuffle"]
let currentIPods = iPods.subtracting(discontinuedIPods) // ["iPod touch"]
Мы также можем сформировать пересечение двух множеств, т.е. найти все элементы, которые присутствуют в обоих:
let touchscreen: Set = ["iPhone", "iPad", "iPod touch", "iPod nano"]
let iPodsWithTouch = iPods.intersection(touchscreen)
// ["iPod nano", "iPod touch"]
Или мы можем сформировать объединение двух множеств, т.е. объединить их в одно (удалив дубликаты, конечно):
var discontinued: Set = ["iBook", "PowerBook", "Power Mac"]
discontinued.formUnion(discontinuedIPods)
discontinued
/*
["iPod classic", "Power Mac", "iPod nano", "iPod shuffle", "iBook",
"iPod mini", "PowerBook"]
*/
Здесь мы использовали мутирующую версию formUnion, чтобы изменить оригинальное множество (которое, как результат, должно быть объявлено с помощью var). Почти все операции над множествами имеют как немутирующие, так и мутирующие формы, и последние имеют префикс form. Для еще большего количества операций над множествами ознакомьтесь с протоколом SetAlgebra.
Использование множеств внутри замыканий Link to heading
Словари и множества могут быть очень полезными структурами данных для использования внутри ваших функций, даже если вы не передаете их вызывающему коду. Например, если мы хотим написать расширение для Sequence, чтобы получить все уникальные элементы в последовательности, мы можем легко поместить элементы в множество и вернуть его содержимое. Однако это не будет стабильным, так как у множества нет определенного порядка, и входные элементы могут быть переупорядочены в результате. Чтобы исправить это, мы можем написать расширение, которое сохраняет порядок, используя внутреннее множество для учета:
extension Sequence where Element: Hashable {
func unique() -> [Element] {
var seen: Set<Element> = []
return filter { element in
if seen.contains(element) {
return false
} else {
seen.insert(element)
return true
}
}
}
}
[1, 2, 3, 12, 1, 3, 4, 5, 6, 4, 6].unique() // [1, 2, 3, 12, 4, 5, 6]
Метод выше позволяет нам находить все уникальные элементы в последовательности, при этом сохраняя оригинальный порядок (с условием, что элементы должны быть Hashable). Внутри замыкания, которое мы передаем в filter, мы ссылаемся на переменную seen, которую мы определили вне замыкания, тем самым поддерживая состояние на протяжении нескольких итераций замыкания. В главе о функциях мы рассмотрим эту технику более подробно.
Ranges Link to heading
Range — это интервал значений, который определяется его нижней и верхней границами. Вы создаете диапазоны с помощью двух операторов диапазона: ..< для полузакрытых диапазонов, которые не включают свою верхнюю границу, и ... для закрытых диапазонов, которые включают обе границы:
// от 0 до 9, 10 не включен.
let singleDigitNumbers = 0..<10
Array(singleDigitNumbers) // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
// "z" включен.
let lowercaseLetters = Character("a")...Character("z")
Также существуют префиксные и постфиксные варианты этих операторов, которые используются для выражения односторонних диапазонов:
let fromZero = 0...
let upToZ = ..<Character("z")
Существует пять различных конкретных типов, которые представляют диапазоны, и каждый тип захватывает разные ограничения на значение. Два самых основных типа — это Range (полузакрытый диапазон, созданный с помощью ..<) и ClosedRange (созданный с помощью ...). Оба имеют обобщенный параметр Bound: единственное требование заключается в том, что Bound должен быть Comparable. Например, выражение lowercaseLetters выше имеет тип ClosedRange<Character>.
Самая базовая операция над диапазоном — это проверка, содержит ли он определенные элементы:
singleDigitNumbers.contains(9) // true
lowercaseLetters.overlaps("c"..<"f") // true
Существуют отдельные типы для полузакрытых и закрытых диапазонов, потому что у обоих есть свое место:
→ Только полузакрытый диапазон может представлять пустой интервал (когда нижняя и верхняя границы равны, как в 5..<5).
→ Только закрытый диапазон может содержать максимальное значение, которое его тип элемента может представлять (например, 0...Int.max). Полузакрытый диапазон всегда требует как минимум одно представимое значение, которое больше, чем наибольшее значение в диапазоне.
Countable Ranges Link to heading
Диапазоны кажутся естественным выбором для последовательностей или коллекций. И действительно, вы можете перебрать диапазон целых чисел или рассматривать его как коллекцию:
for i in 0..<10 {
print("\(i)", terminатор: " ")
} // 0 1 2 3 4 5 6 7 8 9
singleDigitNumbers.last // Optional(9)
Но не все диапазоны можно использовать таким образом. Например, компилятор не позволит нам итерировать по диапазону символов:
// Ошибка: Тип 'Character' не соответствует протоколу 'Strideable'.
for c in lowercaseLetters {
...
}
(Причина, по которой итерация по символам не так проста, как может показаться, связана с Unicode. Мы подробно рассмотрим эту проблему в главе о строках.)
Что здесь происходит? Диапазон соответствует протоколам коллекции условно, если его тип элементов соответствует протоколу Strideable (т.е. вы можете перейти от одного элемента к другому, добавив смещение) и если шаги шага сами являются целыми числами:
extension Range: Sequence
where Bound: Strideable, Bound.Stride: SignedInteger { /* ... */ }
extension Range: Collection, BidirectionalCollection, RandomAccessCollection
where Bound: Strideable, Bound.Stride: SignedInteger { /* ... */ }
(Мы подробно рассмотрим протоколы Sequence, Collection, BidirectionalCollection и RandomAccessCollection в главе о протоколах коллекций.)
Другими словами, диапазон должен быть счетным, чтобы его можно было итерировать. Допустимые границы для счетных диапазонов (т.е. соответствующие ограничениям) включают целочисленные и указательные типы — но не типы с плавающей запятой, из-за ограничения на целочисленный тип шага. Если вам нужно итерировать по последовательным значениям с плавающей запятой, вы можете использовать функции stride(from:to:by) и stride(from:through:by), чтобы создать такую последовательность.
До того, как условная совместимость с протоколами была введена в Swift 4.1 и 4.2, стандартная библиотека включала конкретные типы с именами CountableRange и CountableClosedRange для различения счетных и несчетных диапазонов. Эти имена все еще существуют как типовые псевдонимы для обратной совместимости. Вы также можете использовать их как сокращение для громоздкого выражения “диапазон плюс ограничения”, как указывает комментарий в стандартной библиотеке:
// Примечание: это не только для совместимости; это считается полезным
// сокращением.
public typealias CountableRange<Bound: Strideable> = Range<Bound>
where Bound.Stride: SignedInteger
Частичные диапазоны (Partial Ranges) Link to heading
Частичные диапазоны создаются с использованием … или ..< в качестве префиксного или постфиксного оператора. Эти диапазоны называются частичными, потому что у них отсутствует одна из границ. Например, 0… описывает диапазон, который начинается с нуля и не имеет верхней границы. Существует три разных типа:
let fromA: PartialRangeFrom<Character> = Character("a")...
let throughZ: PartialRangeThrough<Character> = ...Character("z")
let upto10: PartialRangeUpTo<Int> = ..<10
Таким же образом, как CountableRange является типовым псевдонимом для диапазонов с элементами, которые могут быть шаговыми, CountablePartialRangeFrom является типовым псевдонимом для PartialRangeFrom, но с более строгими ограничениями.
Когда мы итерируемся по счетному PartialRangeFrom, итерация начинается с lowerBound и многократно вызывает advanced(by:1). Если вы используете такой диапазон в цикле for, вы должны позаботиться о добавлении условия выхода, иначе вы можете оказаться в бесконечном цикле (или произойдет сбой, когда счетчик переполнится). PartialRangeThrough и PartialRangeUpTo не могут быть итерированы, независимо от того, являются ли их типы элементов шаговыми или нет, потому что у них обоих отсутствует нижняя граница.
Range Expressions Link to heading
Все пять типов диапазонов соответствуют протоколу RangeExpression. Сам протокол достаточно мал, чтобы его можно было напечатать в этой книге. Он позволяет вам узнать, содержится ли элемент в диапазоне, а также, имея коллекцию, может вычислить полностью определенный диапазон для вас:
public protocol RangeExpression {
associatedtype Bound: Comparable
func contains(_ element: Bound) -> Bool
func relative<C>(to collection: C) -> Range<Bound>
where C: Collection, Self.Bound == C.Index
}
Для частичных диапазонов с отсутствующей нижней границей метод relative(to:) добавляет startIndex коллекции в качестве нижней границы. Для частичных диапазонов с отсутствующей верхней границей метод будет использовать endIndex коллекции. Частичные диапазоны позволяют использовать очень компактный синтаксис для нарезки коллекций:
let numbers = [1, 2, 3, 4]
numbers[2...] // [3, 4]
numbers[..<1] // [1]
numbers[1...2] // [2, 3]
Это работает, потому что соответствующее объявление подскрипта в протоколе Collection принимает RangeExpression, а не один из пяти конкретных типов диапазонов. Вы даже можете опустить обе границы, чтобы получить срез всей коллекции:
numbers[...] // [1, 2, 3, 4]
type(of: numbers[...]) // ArraySlice<Int>
(Это реализовано как особый случай в стандартной библиотеке. Такой неограниченный диапазон пока не является действительным RangeExpression, но в конечном итоге он должен им стать.)
Если возможно, попробуйте скопировать подход стандартной библиотеки и сделать так, чтобы ваши собственные функции принимали RangeExpression, а не конкретный тип диапазона. Это не всегда возможно, потому что протокол не дает вам доступ к границам диапазона, если вы не находитесь в контексте коллекции, но если это так, вы предоставите пользователям ваших API гораздо больше свободы передавать любые выражения диапазона, которые им нравятся.
Range Set Link to heading
RangeSet — это серия диапазонов одного и того же типа элементов. Его основное применение заключается в упрощении и повышении эффективности работы с разрозненными наборами индексов коллекции. Конечно, вы можете использовать Set для этой задачи, но RangeSet более эффективно использует память, так как может объединять смежные значения в один диапазон. Предположим, у вас есть таблица с 1,000 элементов, и вы хотите использовать набор для управления индексами строк, которые выбрал пользователь. Set должен хранить до 1,000 элементов, в зависимости от того, сколько строк выбрано. С другой стороны, RangeSet хранит смежные диапазоны, поэтому выбор первых 500 строк в таблице требует хранения всего лишь двух целых чисел (нижней и верхней границ выбора).
RangeSet имеет свойство ranges, которое предоставляет интерфейс коллекции для диапазонов:
var indices = RangeSet(1..<5)
indices.insert(contentsOf: 11..<15)
/*show*/ Array(indices.ranges)
Диапазоны всегда возвращаются в порядке возрастания, независимо от порядка вставки, и они никогда не перекрываются и не соприкасаются. Если вы предпочитаете коллекцию отдельных элементов, используйте flatMap:
/*show*/ Array(indices.ranges.flatMap { $0 })
/*show*/ let evenIndices = indices.ranges
.flatMap { $0 }
.filter { $0 % 2 == 0 }
RangeSet не соответствует протоколу SetAlgebra, но реализует подмножество операций SetAlgebra для формирования объединений, пересечений и т.д.
Тип RangeSet еще не является частью стандартной библиотеки, но он прошел через процесс Swift Evolution как предложение SE-0270. В настоящее время он доступен как часть пакета Standard Library Preview, который служит “площадкой для испытаний” для новых дополнений в стандартную библиотеку, позволяя быстро адаптироваться и обеспечивая реальный период тестирования, в течение которого все еще могут быть внесены разрушительные изменения.
Резюме Link to heading
В этой главе мы обсудили несколько различных коллекций: массивы, словари, множества и диапазоны. Мы также рассмотрели ряд операций, которые доступны для каждой из этих коллекций, и как писать мощные алгоритмы, комбинируя эти операции. Мы увидели, как встроенные коллекции Swift позволяют контролировать изменяемость с помощью let и var, а также как разобраться во всех различных типах диапазонов.
По сравнению с другими языками, стандартная библиотека Swift предлагает относительно немного универсальных типов коллекций. Если вам не хватает какой-либо конкретной структуры данных, обратите внимание на пакет Swift Collections, где члены команды стандартной библиотеки и остальное сообщество Swift работают над высококачественными реализациями для общих структур данных, таких как двусторонняя очередь и упорядоченные варианты Set и Dictionary.
Строки также являются коллекциями. Мы не обсуждали их здесь, потому что им посвящена отдельная глава.
Мы вернемся к теме этой главы в разделе о Протоколах Коллекций, где мы подробно обсудим протоколы, на которых основаны коллекции Swift.
Опциональные параметры Link to heading
3 Link to heading
Sentinel Values Link to heading
Одним из крайне распространенных паттернов в программировании является наличие операции, которая может или не может вернуть значение. Возможно, отсутствие возвращаемого значения является ожидаемым результатом, когда вы достигли конца файла, который читали, как в следующем фрагменте на C:
int ch;
while ((ch=getchar())!=EOF) {
printf("Read character %c\n", ch);
}
printf("Reached end-of-file\n");
EOF — это просто определение для -1. Пока в файле есть символы, getchar возвращает их. Но если конец файла достигнут, getchar возвращает -1.
Или, возможно, отсутствие возвращаемого значения означает «не найдено», как в этом фрагменте на C++:
auto vec = {1, 2, 3};
auto iterator = std::find(vec.begin(), vec.end(), someValue);
if (iterator != vec.end()) {
std::cout << "vec contains " << *iterator << std::endl;
}
Здесь vec.end() — это итератор «один после конца» контейнера; это специальный итератор, который вы можете проверить на соответствие концу контейнера, но который вы никогда не должны использовать для доступа к значению — аналогично индексу конца коллекции в Swift. Функция find использует его, чтобы указать, что такого значения в контейнере нет.
Или, возможно, значение не может быть возвращено, потому что что-то пошло не так во время обработки функции. Вероятно, самым известным примером является нулевой указатель. Этот на вид безобидный фрагмент кода на Java, скорее всего, вызовет NullPointerException:
int i = Integer.getInteger("123");
Дело в том, что Integer.getInteger не разбирает строки в целые числа, а получает целочисленное значение системного свойства с именем «123». Это свойство, вероятно, не существует, в этом случае getInteger возвращает null. Когда null затем распаковывается в int, Java выбрасывает исключение.
Или возьмите этот пример на Objective-C:
[[NSString alloc] initWithContentsOfURL:url encoding:NSUTF8StringEncoding error:&error];
Этот инициализатор может вернуть nil, в этом случае — и только тогда — указатель на ошибку должен быть проверен. Нет гарантии, что указатель на ошибку действителен, если инициализатор возвращает не nil.
Во всех вышеперечисленных примерах функция возвращает специальное «магическое» значение, чтобы указать, что она не вернула реальное значение. Магические значения, подобные этим, называются «сентинельными значениями».
Но этот подход проблематичен. Возвращенный результат выглядит и ощущается как реальное значение. int со значением -1 все еще является допустимым целым числом, но вы никогда не хотите его выводить. vec.end() — это итератор, но результаты неопределены, если вы попытаетесь его использовать. И всем нравится видеть дамп стека, когда ваша Java-программа выбрасывает NullPointerException.
В отличие от Java, Objective-C позволяет отправлять сообщения nil. Это «безопасно» в том смысле, что среда выполнения гарантирует, что возвращаемое значение от сообщения к nil всегда будет эквивалентно нулю, т.е. nil для типов объектов, 0 для числовых типов и так далее. Если сообщение возвращает структуру, все ее члены будут инициализированы нулем. Учитывая это, рассмотрим следующий фрагмент для поиска подстроки:
NSString *someString = ...;
if ([someString rangeOfString:@"Swift"].location != NSNotFound) {
NSLog(@"Someone mentioned Swift!");
}
Если someString равно nil, случайно или намеренно, сообщение rangeOfString: вернет инициализированный NSRange. Следовательно, его .location будет равен нулю, и неравенство по сравнению с NSNotFound (который определен как NSIntegerMax) будет выполнено успешно. Таким образом, тело оператора if будет выполнено, когда этого не должно быть.
Нулевые ссылки вызывают так много проблем, что Тони Хоар, которому приписывают их создание в 1965 году, называет их своей «миллиардной долларовой ошибкой»:
В то время я разрабатывал первую комплексную систему типов для ссылок в объектно-ориентированном языке (ALGOL W). Моя цель заключалась в том, чтобы гарантировать, что все использование ссылок должно быть абсолютно безопасным, с проверкой, выполняемой автоматически компилятором. Но я не мог устоять перед искушением вставить нулевую ссылку, просто потому что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и сбоям системы, которые, вероятно, вызвали миллиард долларов боли и ущерба за последние сорок лет.
Еще одной проблемой с сентинельными значениями является то, что их правильное использование требует предварительных знаний. Иногда существует идиома, которая широко соблюдается в сообществе, как, например, итератор конца в C++ или соглашения по обработке ошибок в Objective-C. Если таких правил не существует или вы о них не знаете, вам придется обращаться к документации. Более того, нет способа для функции указать, что она не может завершиться успешно. Если вызов возвращает указатель, этот указатель может никогда не быть nil. Но нет способа это узнать, кроме как прочитать документацию, и даже тогда документация может быть неверной.
Замена значений-сентинелов на перечисления Link to heading
Конечно, каждый хороший программист знает, что магические числа — это плохо. Большинство языков поддерживают какой-то вид перечисляемого типа, который является более безопасным способом представления набора дискретных возможных значений для типа. Swift развивает концепцию перечислений с помощью “ассоциированных значений”. Это значения перечисления, которые также могут иметь другое значение, связанное с ними. Мы подробно рассмотрим перечисления в главе о перечислениях. На данный момент достаточно знать, что Optional определяется как перечисление:
enum Optional<Wrapped> {
case none
case some(Wrapped)
}
Единственный способ получить ассоциированное значение перечисления — это с помощью сопоставления с образцом, например, в операторе switch или в операторе if case let. Кодирование состояния “отсутствующего значения” в системе типов делает код более выразительным, потому что вызывающие API могут сразу увидеть, нужно ли им обрабатывать этот случай. И в отличие от значения-сентинела, вы не можете случайно использовать значение, встроенное в Optional, не проверив и не распаковав его явно.
Таким образом, вместо возврата значения-сентинела, эквивалент Swift для nil — это метод firstIndex(of:), который возвращает Optional<Index> с реализацией, несколько похожей на эту:
extension Collection where Element: Equatable {
func firstIndex(of element: Element) -> Optional<Index> {
var idx = startIndex
while idx != endIndex {
if self[idx] == element {
return .some(idx)
}
formIndex(after: &idx)
}
// Не найдено, возвращаем .none.
return .none
}
}
Поскольку опционалы являются основополагающими для Swift, существует много синтаксической поддержки для упрощения этого: Optional<Index> можно записать как Index?; опционалы соответствуют протоколу ExpressibleByNilLiteral, так что вы можете писать nil вместо .none; и не-опциональные значения (например, idx) автоматически “продвигаются” до опционалов, когда это необходимо, так что вы можете писать return idx вместо return .some(idx).
Синтаксический сахар эффективно скрывает истинную природу типа Optional. Стоит помнить, что в этом нет ничего магического; это просто обычное перечисление, и если бы его не существовало, вы могли бы определить его сами.
Теперь нет способа, чтобы пользователь случайно использовал значение, не проверив, действительно ли оно валидно:
var array = ["one", "two", "three"]
let idx = array.firstIndex(of: "four") // возвращает Optional<Int>.
// Ошибка компиляции: remove(at:) принимает Int, а не Optional<Int>.
array.remove(at: idx)
Вместо этого вам придется “распаковать” опционал, чтобы получить индекс внутри, предполагая, что вы не получили none:
var array = ["one", "two", "three"]
switch array.firstIndex(of: "four") {
case .some(let idx):
array.remove(at: idx)
case .none:
break // Ничего не делать.
}
Этот оператор switch записывает синтаксис перечисления для опционалов в полном виде, включая распаковку ассоциированного значения в случае some. Это отлично для безопасности, но не очень приятно для чтения или написания. Более лаконичной альтернативой является использование суффикса ? для сопоставления с некоторым опциональным значением, и вы можете использовать литерал nil для сопоставления с none:
switch array.firstIndex(of: "four") {
case let idx?: // Эквивалентно .some(let idx)
array.remove(at: idx)
case nil:
break // Ничего не делать.
}
Но это все еще громоздко. Давайте рассмотрим все другие способы, которыми вы можете сделать обработку опционалов короткой и ясной, в зависимости от вашего случая использования.
Обзор дополнительных техник Link to heading
Опционалы имеют много дополнительных возможностей, встроенных в язык. Некоторые из приведенных ниже примеров могут показаться очень простыми, если вы уже какое-то время пишете на Swift, но важно убедиться, что вы хорошо понимаете все эти концепции, так как мы будем использовать их снова и снова на протяжении всей книги.
if let Link to heading
Привязка опциональных значений с помощью if let — это всего лишь шаг от оператора switch, описанного выше. Оператор if let проверяет, является ли опциональное значение ненулевым, и если это так, он извлекает опциональное значение. Тип idx — это Int (неопциональный), и idx доступен только в пределах области видимости оператора if let:
var array = ["one", "two", "three", "four"]
if let idx = array.firstIndex(of: "four") {
array.remove(at: idx)
}
Вы также можете использовать логические условия вместе с if let. Предположим, вы не хотите удалять элемент, если он оказался первым в массиве:
if let idx = array.firstIndex(of: "four"), idx != array.startIndex {
array.remove(at: idx)
}
Вы также можете привязывать несколько значений в одном операторе if. Более того, последующие значения могут полагаться на успешное извлечение предыдущих. Это очень полезно, когда вы хотите сделать несколько вызовов функций, которые сами возвращают опциональные значения. Например, инициализаторы URL и UIImage в следующем примере являются “неудачными” — то есть они могут вернуть nil, если ваш URL неправильно сформирован или если данные не являются изображением. Инициализатор Data может выбросить ошибку, и, используя try?, вы можете преобразовать его в опциональное значение. Все три можно связать вместе, как это:
let urlString = "https://www.objc.io/logo.png"
if let url = URL(string: urlString),
let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
let view = UIImageView(image: image)
PlaygroundPage.current.liveView = view
}
Синхронный инициализатор Data(contentsOfURL:) будет блокировать свой поток на время загрузки. Это нормально для быстрого примера, но не рекомендуется для производственного кода. Всегда используйте асинхронный сетевой API, такой как URLSession.data(from:delegate:).
Вы можете свободно комбинировать и сочетать привязки опциональных значений, логические условия и привязки case let в одном операторе if.
while let Link to heading
Очень похоже на оператор if let, while let — это цикл, который завершается, когда его условие возвращает nil. Функция readLine стандартной библиотеки возвращает необязательную строку из стандартного ввода. Как только конец ввода достигнут, она возвращает nil. Чтобы реализовать очень базовый эквивалент команды Unix cat, вы используете while let:
while let line = readLine() {
print(line)
}
Так же, как и в операторах if let, вы всегда можете добавить логическое условие к вашему необязательному связыванию. Поэтому, если вы хотите завершить этот цикл при достижении EOF или пустой строки, добавьте условие для обнаружения пустой строки. Обратите внимание, что как только условие становится false, цикл завершается (вы можете ошибочно думать, что логическое условие работает как фильтр):
while let line = readLine(), !line.isEmpty {
print(line)
}
Как мы увидим в главе о протоколах коллекций, цикл for-in seq требует, чтобы seq соответствовал протоколу Sequence. Этот протокол предоставляет метод makeIterator, который возвращает итератор, который, в свою очередь, имеет метод next. Метод next возвращает значения, пока последовательность не исчерпана, а затем возвращает nil. whilelet идеально подходит для этого:
let array = [1, 2, 3]
var iterator = array.makeIterator()
while let i = iterator.next() {
print(i, terminator: " ")
} // 1 2 3
Таким образом, учитывая, что циклы for на самом деле являются просто циклами while, неудивительно, что они также поддерживают логические условия, хотя и с ключевым словом where:
for i in 0..<10 where i % 2 == 0 {
print(i, terminator: " ")
} // 0 2 4 6 8
Обратите внимание, что условие where выше не работает так же, как логическое условие в цикле while. В цикле while итерация останавливается, как только значение становится false, в то время как в цикле for оно функционирует как фильтр. Если бы мы переписали вышеуказанный цикл for, используя while, это выглядело бы так:
var iterator2 = (0..<10).makeIterator()
while let i = iterator2.next() {
guard i % 2 == 0 else { continue }
print(i)
}
Doubly Nested Optionals Link to heading
Это хорошее время, чтобы указать на то, что тип, который может быть опциональным, может сам по себе быть опциональным, что приводит к опционалам, вложенным внутри опционалов. Чтобы понять, почему это не просто странный крайний случай или что-то, что компилятор должен автоматически объединить, предположим, что у вас есть массив строк, представляющих числа, которые вы хотите преобразовать в целые числа. Вы можете пропустить их через map, чтобы преобразовать их:
let stringNumbers = ["1", "2", "three"]
let maybeInts = stringNumbers.map { Int($0) } // [Optional(1), Optional(2), nil]
Теперь у вас есть массив Optional<Int> — т.е. Int? — потому что Int.init(String) может завершиться неудачей, так как строка может не содержать допустимого целого числа. Здесь последний элемент будет nil, так как “three” не является целым числом.
При переборе массива с помощью for вы бы справедливо ожидали, что каждый элемент будет опциональным целым числом, потому что именно это и содержит maybeInts:
for maybeInt in maybeInts {
// maybeInt is an Int?
// Два числа и `nil`.
}
Теперь рассмотрим, что реализация for...in является сокращением для техники цикла while, описанной выше. То, что возвращается из iterator.next(), будет Optional<Optional<Int>> — или Int?? — потому что next оборачивает каждый элемент в последовательности внутри опционала. while let распаковывает его, чтобы проверить, что это не nil, и пока это не nil, связывает распакованное значение и выполняет тело:
var iterator = maybeInts.makeIterator()
while let maybeInt = iterator.next() {
print(maybeInt, terminator: " ")
}
// Optional(1) Optional(2) nil
Когда цикл доходит до последнего элемента — nil от “three” — то, что возвращается из next, является ненулевым значением: .some(nil). Это распаковывается и связывает то, что внутри (т.е. nil), с maybeInt. Без вложенных опционалов это было бы невозможно.
Кстати, если вы когда-либо захотите перебрать только ненулевые значения с помощью for, вы можете использовать сопоставление с образцом:
for case let i? in maybeInts {
// i будет Int, а не Int?
print(i, terminator: " ")
}
// 1 2
Или только nil значения:
for case nil in maybeInts {
// Будет выполнено один раз для каждого nil.
print("No value")
}
// No value
Это использует “шаблон” x?, который соответствует только ненулевым значениям. Это сокращение для .some(x), так что цикл можно было бы записать так:
for case let .some(i) in maybeInts {
print(i)
}
Это основанное на случае сопоставление с образцом — это способ применения тех же правил, которые работают в операторе switch, к if, for и while. Это наиболее полезно с опционалами, но также имеет и другие применения — например:
let j = 5
if case 0..<10 = j {
print("\(j) within range")
} // 5 within range
Мы подробно обсудим сопоставление с образцом в главе о перечислениях.
if var and while var Link to heading
Вместо let вы можете использовать var с if, while и for. Это позволяет вам изменять переменную внутри тела оператора:
let number = "1"
if var i = Int(number) {
i += 1
print(i)
} // 2
Но обратите внимание, что i будет локальной копией; любые изменения i не повлияют на значение внутри оригинального опционала. Опционалы являются типами значений, и разыменование их копирует значение внутри.
Область видимости необернутых опционалов Link to heading
Иногда ограничивает, что доступ к необернутой переменной возможен только внутри блока if, в котором она была определена. Например, рассмотрим свойство first для массивов — свойство, которое возвращает опционал первого элемента или nil, когда массив пуст. Это удобный сокращенный вариант для следующего распространенного кода:
let array = [1, 2, 3]
if !array.isEmpty {
print(array[0])
}
// За пределами блока компилятор не может гарантировать, что array[0] действителен.
Использование свойства first предпочтительнее, потому что вам нужно развернуть опционал, чтобы использовать его — вы не можете случайно забыть:
if let firstElement = array.first {
print(firstElement)
}
// За пределами блока вы не можете использовать firstElement.
Необернутое значение доступно только внутри блока if let. Это здорово, но это непрактично, если цель оператора if — выйти из функции, когда не выполнено какое-то условие. Этот ранний выход может помочь избежать надоедливой вложенности или повторных проверок позже в функции. Вы можете написать следующее:
func doStuff(withArray a: [Int]) {
if a.isEmpty {
return
}
// Теперь используйте a[0] или a.first! безопасно.
}
Здесь связывание if let не сработает, потому что связанная переменная не будет в области видимости после блока if. Но вы все равно можете быть уверены, что массив будет содержать хотя бы один элемент, поэтому принудительное развертывание первого элемента безопасно, даже если синтаксис все еще не привлекателен.
Одним из вариантов использования необернутого опционала за пределами области, в которой он был связан, является полагание на возможности отложенной инициализации Swift. Рассмотрим следующий пример, который повторно реализует часть свойства pathExtension из URL и NSString:
extension String {
var fileExtension: String? {
let period: String.Index
if let idx = lastIndex(of: ".") {
period = idx
} else {
return nil
}
let extensionStart = index(after: period)
return String(self[extensionStart...])
}
}
"hello.txt".fileExtension // Optional("txt")
Компилятор проверяет ваш код, чтобы подтвердить, что есть только два возможных пути: один, в котором функция возвращает значение раньше, и другой, где period правильно инициализирован. Никак не может быть, чтобы period был nil (он не опционален) или не инициализирован (Swift не позволит вам использовать переменную, которая не была инициализирована). Поэтому после оператора if код может быть написан без необходимости беспокоиться об опционалах.
Тем не менее, два предыдущих примера довольно неуклюжи. На самом деле, что нужно, так это нечто вроде if not let — что именно и делает guard let:
func doStuff(withArray a: [Int]) {
guard let firstElement = a.first else {
return
}
// firstElement здесь развернут.
}
И второй пример становится гораздо яснее:
extension String {
var fileExtension: String? {
guard let period = lastIndex(of: ".") else {
return nil
}
let extensionStart = index(after: period)
return String(self[extensionStart...])
}
}
В блок else здесь может входить все, включая несколько операторов, как в if...else. Единственное требование заключается в том, что блок else должен покинуть текущую область видимости. Это может означать return, выброс ошибки или вызов fatalError (или любой другой функции, которая возвращает Never). Если бы guard находился в цикле, break или continue также были бы допустимы.
Функция с типом возвращаемого значения Never сигнализирует компилятору, что она никогда не вернется. Существуют два распространенных типа функций, которые делают это: те, которые прерывают программу, такие как fatalError; и те, которые работают на протяжении всей жизни программы, как dispatchMain. Компилятор использует эту информацию для своей диагностики управления потоком. Например, ветвь else оператора guard должна либо покинуть текущую область видимости, либо вызвать одну из функций, которые никогда не возвращаются.
Never — это то, что называется необитаемым типом. Это тип, у которого нет допустимых значений и, следовательно, не может быть сконструирован. Функция, объявленная для возврата необитаемого типа, никогда не может вернуть значение нормально.
В Swift необитаемый тип реализован как enum, который не имеет случаев:
public enum Never { }
Обычно вам не нужно определять свои собственные функции, которые никогда не возвращаются, если только вы не пишете обертку для fatalError или preconditionFailure. Один интересный случай использования возникает, когда вы пишете новый код: скажем, вы работаете над сложным оператором switch, постепенно заполняя все случаи, и компилятор забрасывает вас сообщениями об ошибках из-за пустых меток случаев или отсутствующих значений возврата, в то время как все, что вы хотите сделать, — это сосредоточиться на одном случае, над которым вы работаете. В этой ситуации несколько тщательно размещенных вызовов fatalError() могут творить чудеса, чтобы заставить компилятор замолчать. Рассмотрите возможность написания функции с именем unimplemented(), чтобы лучше передать временный характер этих вызовов:
func unimplemented() -> Never {
fatalError("Этот код еще не реализован.")
}
Never также часто используется в сочетании с Result и подобными типами в обобщенных контекстах. Например, рассмотрим фреймворк реактивного программирования, такой как Combine от Apple. Combine моделирует потоки событий с помощью протокола Publisher с двумя связанными типами: Output и Failure. Output описывает тип полезной нагрузки эмитируемого события, в то время как Failure представляет случай ошибки.
Объект текстового поля может предоставить AnyPublisher<String, Never>, который генерирует событие каждый раз, когда пользователь редактирует текст. Использование Never в качестве типа ошибки указывает программисту и компилятору, что этот издатель никогда не будет генерировать ошибку, поскольку такое значение невозможно сконструировать.
Swift тщательно различает разные виды “ничто”. В дополнение к nil и Never, есть также Void, который является другим способом записи пустого кортежа:
public typealias Void = ()
Наиболее распространенное использование Void или () — это типы функций, которые ничего не возвращают, но у него есть и другие применения. Например, издатель для объекта кнопки будет генерировать событие, когда пользователь нажимает на кнопку, но у него нет дополнительной полезной нагрузки для отправки — его поток событий должен иметь тип AnyPublisher<(), Never>.
Как сказал Дэвид Смит, Swift делает четкое различие между “отсутствием вещи” (nil), “присутствием ничего” (Void) и “вещью, которая не может быть” (Never).
Так же, как и if, guard не ограничивается только связыванием. guard может принимать любое условие, которое вы можете найти в обычном операторе if, поэтому пример с пустым массивом можно переписать с его использованием:
func doStuff2(withArray a: [Int]) {
guard !a.isEmpty else { return }
// Теперь используйте a[0] или a.first! безопасно.
}
В отличие от случая связывания опционала, этот guard не является большим выигрышем — на самом деле, он немного более многословен, чем оригинальная версия. Но все же стоит рассмотреть возможность использования этого в любой ситуации с ранним выходом. Во-первых, иногда (хотя не в этом случае) инверсия логического условия может сделать вещи более ясными. Кроме того, guard является четким сигналом при чтении кода. Он говорит: “Мы продолжаем только в том случае, если выполняется следующее условие.” Наконец, компилятор Swift проверит, что вы определенно покидаете текущую область видимости, и вызовет ошибку компиляции, если вы этого не сделаете. По этой причине мы рекомендуем использовать guard, даже когда if мог бы сработать.
Optional Chaining Link to heading
В Objective-C отправка сообщения nil не приводит к ошибке. В Swift тот же эффект можно достичь с помощью “опциональной цепочки”:
delegate?.callback()
В отличие от Objective-C, компилятор Swift заставит вас признать, что получатель может быть nil. Знак вопроса является ясным сигналом для читателя о том, что метод может не быть вызван.
Когда метод, который вы вызываете через опциональную цепочку, возвращает результат, этот результат также будет опциональным. Рассмотрим следующий код, чтобы понять, почему это должно быть так:
let str: String? = "Never say never"
// Мы хотим, чтобы upper был строкой в верхнем регистре.
let upper: String
if str != nil {
upper = str!.uppercased()
} else {
// На данном этапе нет разумного действия.
fatalError("Не знаю, что делать дальше...")
}
Если str не равен nil, upper будет иметь желаемое значение. Но если str равен nil, то upper не может быть установлен в значение. Поэтому в случае опциональной цепочки upper2 должен быть опциональным, чтобы учесть возможность, что str мог быть nil:
let upper2 = str?.uppercased() // Optional("NEVER SAY NEVER")
Вы не ограничены одним вызовом метода после знака вопроса. Как подразумевает термин “опциональная цепочка”, вы можете связывать вызовы для опциональных значений:
let lower = str?.uppercased().lowercased() // Optional("never say never")
Это может показаться немного удивительным. Разве мы только что не сказали, что результат опциональной цепочки является опциональным? Так почему же вам не нужен знак вопроса после uppercased()? Это связано с тем, что опциональная цепочка является операцией “уплощения”. Если str?.uppercased() возвращает опциональное значение, и мы вызываем ?.lowercased() на нем, то логически мы получили бы опциональное опциональное значение. Но мы просто хотим обычное опциональное значение, поэтому вместо этого мы пишем второй связанный вызов без знака вопроса, чтобы отразить тот факт, что опциональность уже захвачена.
С другой стороны, если метод uppercased сам возвращает опциональное значение, тогда нам нужно добавить второй знак вопроса, чтобы выразить, что мы связываем это опциональное значение. Например, давайте расширим тип Int с вычисляемым свойством, названным half. Это свойство возвращает результат деления целого числа на два, но только если число достаточно велико для деления. Когда число меньше двух, оно возвращает nil:
extension Int {
var half: Int? {
guard self < -1 || self > 1 else { return nil }
return self / 2
}
}
Поскольку вызов half возвращает опциональный результат, нам нужно продолжать добавлять знак вопроса при его повторном вызове. В конце концов, на каждом шаге функция может вернуть nil:
20.half?.half?.half // Optional(2)
Обратите внимание, что компилятор все еще достаточно умен, чтобы уплощать тип результата для нас. Тип выражения выше — Int?, а не Int??? , как вы могли бы ожидать. Последний вариант дал бы вам больше информации — а именно, какая часть цепочки не удалась — но это также сделало бы гораздо более громоздким работу с результатом, разрушая удобство, которое добавляет опциональная цепочка изначально.
До сих пор мы видели опциональную цепочку для вызовов методов и доступа к свойствам. Она также применяется к подскриптам — например:
let dictOfArrays = ["nine": [0, 1, 2, 3]]
dictOfArrays["nine"]?[3] // Optional(3)
Кроме того, вы можете использовать опциональную цепочку для вызова опциональных функций:
let dictOfFunctions: [String: (Int, Int) -> Int] = [
"add": (+),
"subtract": (-)
]
dictOfFunctions["add"]?(1, 1) // Optional(2)
Это удобно в типичных ситуациях обратного вызова, когда класс хранит функцию обратного вызова, чтобы информировать своего владельца, когда происходит событие. Рассмотрим класс TextField:
class TextField {
private(set) var text = ""
var didChange: ((String) -> ())?
// Обработчик событий, вызываемый фреймворком.
func textDidChange(newText: String) {
text = newText
// Вызов обратного вызова, если он не nil.
didChange?(text)
}
}
Свойство didChange хранит функцию обратного вызова, которую текстовое поле вызывает каждый раз, когда пользователь редактирует текст. Поскольку владелец текстового поля не обязан регистрировать обратный вызов, это свойство является опциональным; его начальное значение — nil. Когда приходит время вызвать обратный вызов (в методе textDidChange выше), опциональная цепочка позволяет нам сделать это очень компактным способом.
Опциональные переменные (не let) неявно инициализируются в nil, если вы не присваиваете значение. Это единственное исключение, которое Swift делает из своей строгой политики явной инициализации. Обоснование этого — удобство, и у опционалов есть “очевидное” значение по умолчанию. Тем не менее, некоторые члены команды Swift выразили сожаление по поводу этого дизайнерского решения и предпочли бы изменить его, если бы это не сломало существующий код. Любопытно, что неявная инициализация срабатывает только для объявлений, которые используют …? сокращение для аннотации типа. Если бы мы написали var didChange: Optional<(String) -> ()> выше, компилятор пожаловался бы, что свойство не было инициализировано.
Опциональная цепочка и присваивания Link to heading
Вы даже можете присваивать значения через опциональную цепочку. Предположим, у вас есть опциональная переменная, и если она не равна nil, вы хотите обновить одно из её свойств:
struct Person {
var name: String
var age: Int
}
var optionalLisa: Person? = Person(name: "Лиза Симпсон", age: 8)
// Увеличиваем возраст, если он не равен nil.
if optionalLisa != nil {
optionalLisa!.age += 1
}
Это довольно многословно и неуклюже. Обратите внимание, что вы не можете использовать опциональное связывание в этом случае. Поскольку Person — это структура и, следовательно, тип значения, связанная переменная является локальной копией оригинального значения; изменение первой не изменит последнюю:
if var lisa = optionalLisa {
// Изменение lisa не изменяет optionalLisa.
lisa.age += 1
}
Это сработало бы, если бы Person был классом. Мы подробнее поговорим о различиях между типами значений и ссылочными типами в главе о Структурах и Классах. Тем не менее, это все еще слишком многословно. Вместо этого вы можете присвоить значение через цепочку опционала, и если оно не равно nil, присваивание будет выполнено:
optionalLisa?.age += 1
Странный (но логичный) крайний случай этой функции заключается в том, что она работает для прямого присваивания опциональным значениям. Это совершенно допустимо:
var a: Int? = 5
a? = 10
a // Optional(10)
var b: Int? = nil
b? = 10
b // nil
Обратите внимание на тонкое различие между a = 10 и a? = 10. Первое присваивает новое значение без условий, в то время как второе выполняет присваивание только в том случае, если значение a не равно nil перед присваиванием.
Оператор объединения с nil (Nil-Coalescing Operator) Link to heading
Часто вам нужно развернуть опциональное значение и заменить nil на какое-то значение по умолчанию. Это задача для оператора объединения с nil:
let stringteger = "1"
let number = Int(stringteger) ?? 0
Если строку можно преобразовать в целое число, number будет этим целым числом, развернутым. Если это невозможно и Int.init возвращает nil, будет подставлено значение по умолчанию 0. Таким образом, lhs ?? rhs аналогично lhs != nil ? lhs : rhs.
Если вы переходите на Swift с другого языка, вы можете подумать, что оператор объединения с nil похож на тернарный оператор (a ? b : c). Например, чтобы получить первый элемент из массива, который может быть пустым, вы могли бы написать следующий код:
let array = [1, 2, 3]
!array.isEmpty ? array[0] : 0
Поскольку массивы Swift предоставляют свойство first, которое равно nil, если массив пуст, вы можете использовать оператор объединения с nil вместо этого:
array.first ?? 0 // 1
Это более чисто и понятно — намерение (взять первый элемент массива) очевидно, с значением по умолчанию, добавленным в конце, соединенным с ??, который сигнализирует: “это значение по умолчанию”. Сравните это с версией с тернарным оператором, которая начинается с проверки, затем следует значение и заканчивается значением по умолчанию. И проверка неуклюже отрицательная (альтернативой было бы поставить значение по умолчанию посередине, а фактическое значение в конце). И, как это бывает с опциональными значениями, невозможно забыть, что first является опциональным, и случайно использовать его без проверки, потому что компилятор остановит вас, если вы попытаетесь.
Когда бы вы ни обнаружили, что защищаете оператор с помощью проверки, чтобы убедиться, что оператор действителен, это хороший знак, что опционалы будут лучшим решением. Предположим, что вместо пустого массива вы проверяете значение, которое находится в пределах массива:
array.count > 5 ? array[5] : 0 // 0
В отличие от first и last, получение элемента из массива по индексу не возвращает опциональное значение. Но легко расширить Array, чтобы добавить эту функциональность:
extension Array {
subscript(guarded idx: Int) -> Element? {
guard (startIndex..<endIndex).contains(idx) else {
return nil
}
return self[idx]
}
}
Теперь вы можете написать следующее:
array[guarded: 5] ?? 0 // 0
Объединение также может быть цепочечным — если у вас есть несколько возможных опционалов и вы хотите выбрать первое ненулевое значение, вы можете записать их в последовательности:
let i: Int? = nil
let j: Int? = nil
let k: Int? = 42
i ?? j ?? k ?? 0 // 42
Из-за этой цепочки, если вам когда-либо представят двойное вложенное опциональное значение и вы захотите использовать оператор ??, вы должны быть осторожны, чтобы различать a ?? b ?? c (цепочка) и (a ?? b) ?? c (разворачивание внутренних, а затем внешних слоев):
let s1: String?? = nil
(s1 ?? "inner") ?? "outer" // inner
let s2: String?? = .some(nil)
(s2 ?? "inner") ?? "outer" // outer
Если вы думаете об операторе ?? как о чем-то, аналогичном оператору “или”, вы можете рассматривать if let с несколькими условиями как оператор “и”:
if let n = i, let m = j { }
// аналогично if i != nil && j != nil
Как и оператор ||, оператор ?? использует короткое замыкание: когда мы пишем l ?? r, правая сторона оператора вычисляется только тогда, когда левая сторона равна nil. Это работает, потому что объявление функции для оператора использует @autoclosure для своего второго параметра. Мы обсудим, как работают автозамыкания, в главе о функциях.
Использование опционалов с интерполяцией строк Link to heading
Вы могли заметить, что компилятор выдает предупреждение, когда вы печатаете опциональное значение или используете его в выражении интерполяции строк:
let bodyTemperature: Double? = 37.0
let bloodGlucose: Double? = nil
print(bodyTemperature) // Optional(37.0)
// Warning: Expression implicitly coerced from 'Double?' to Any.
print("Уровень глюкозы в крови: \(bloodGlucose)") // Уровень глюкозы в крови: nil
// Warning: String interpolation produces a debug description
// for an optional value; did you mean to make this explicit?
Это обычно полезно, потому что слишком легко случайно вставить “Optional(…)” или “nil” в текст, отображаемый пользователю. Вы никогда не должны использовать опционалы напрямую в строках, предназначенных для пользователя, и всегда сначала распаковывать их. Поскольку все типы допускаются в интерполяции строк (включая Optional), компилятор не может сделать это жесткой ошибкой, поэтому предупреждение — это действительно лучшее, что он может сделать.
Иногда вы можете захотеть использовать опционал в интерполяции строк — например, для логирования его значения для отладки — и в этом случае предупреждения могут стать раздражающими. Компилятор предлагает несколько способов устранить предупреждение: добавить явное приведение типа с помощью as Any, принудительно распаковать значение с помощью ! (если вы уверены, что оно не может быть nil), обернуть его в String(describing:) или предоставить значение по умолчанию с помощью оператора объединения с nil.
Последний вариант часто является быстрым и довольно удобным решением, но у него есть один недостаток: типы с обеих сторон выражения ?? должны совпадать, поэтому значение по умолчанию, которое вы предоставляете для Double?, должно быть типа Double. Поскольку конечная цель — превратить выражение в строку, было бы удобно, если бы вы могли предоставить строку в качестве значения по умолчанию с самого начала.
Оператор ?? в Swift не поддерживает такого рода несоответствие типов — в конце концов, каков был бы тип выражения, если бы у двух сторон не было общего базового типа? Но легко добавить свой собственный оператор, который специально предназначен для работы с опционалами в интерполяции строк. Назовем его ???:
infix operator ???: NilCoalescingPrecedence
public func ???<T>(optional: T?, defaultValue: @autoclosure () -> String) -> String {
switch optional {
case let value?: return String(describing: value)
case nil: return defaultValue()
}
}
Это принимает любой опционал T? с левой стороны и строку с правой. Если опционал не равен nil, мы распаковываем его и возвращаем его строковое описание. В противном случае мы возвращаем строку по умолчанию, которая была передана. Аннотация @autoclosure гарантирует, что мы будем оценивать второй операнд только при необходимости. В главе о функциях мы рассмотрим это более подробно.
Теперь мы можем написать следующее, и мы не получим никаких предупреждений компилятора:
print("Температура тела: \(bodyTemperature ??? "н/д")")
// Температура тела: 37.0
print("Уровень глюкозы в крови: \(bloodGlucose ??? "н/д")")
// Уровень глюкозы в крови: н/д
Optional map Link to heading
Предположим, у нас есть массив символов, и мы хотим превратить первый элемент в строку:
let characters: [Character] = ["a", "b", "c"]
String(characters[0]) // a
Если массив characters может быть пустым, мы можем использовать конструкцию if let, чтобы создать строку только в том случае, если массив не пуст:
var firstCharAsString: String? = nil
if let char = characters.first {
firstCharAsString = String(char)
}
Теперь, если массив содержит хотя бы один элемент, firstCharAsString будет содержать этот элемент в виде строки. Но если он пуст, firstCharAsString будет равен nil.
Этот шаблон — взять опционал и преобразовать его, если он не равен nil — достаточно распространен, чтобы для этого был метод на опционалах. Он называется map и принимает функцию, которая описывает, как преобразовать содержимое опционала. Вот вышеуказанная функция, переписанная с использованием map:
let firstChar = characters.first.map { String($0) } // Optional("a")
Этот map, конечно, очень похож на map для массивов или других последовательностей. Но вместо того, чтобы работать с последовательностью значений, он работает только с одним значением: возможным значением внутри опционала. Вы можете рассматривать опционал как коллекцию из нуля или одного значения, при этом map либо ничего не делает с нулевыми значениями, либо преобразует одно.
Вот один из способов реализовать map для опционалов:
extension Optional {
func map<U>(transform: (Wrapped) -> U) -> U? {
guard let value = self else { return nil }
return transform(value)
}
}
Опциональный map особенно удобен, когда вы уже хотите получить опциональный результат. Предположим, вы хотите написать еще одну вариацию reduce для массивов. Вместо того, чтобы принимать начальное значение, она использует первый элемент в массиве (в некоторых языках это может называться reduce1, но мы назовем это reduce и полагаемся на перегрузку).
Из-за возможности того, что массив может быть пустым, результат должен быть опциональным — без начального значения, что еще это может быть? Вы можете написать это так:
extension Array {
func reduce( _ nextPartialResult: (Element, Element) -> Element) -> Element? {
// first будет nil, если массив пуст.
guard let fst = first else { return nil }
return dropFirst().reduce(fst, nextPartialResult)
}
}
И вы можете использовать это так:
[1, 2, 3, 4].reduce(+) // Optional(10)
Поскольку опциональный map возвращает nil, если опционал равен nil, наша вариация reduce может быть переписана с использованием одного оператора return (и без guard):
extension Array {
func reduce_alt( _ nextPartialResult: (Element, Element) -> Element) -> Element? {
first.map {
dropFirst().reduce($0, nextPartialResult)
}
}
}
Optional.flatMap Link to heading
Как мы видели в главе о встроенных коллекциях, часто возникает необходимость применить функцию к коллекции, которая возвращает коллекцию, но собрать результаты в одном массиве, а не в массиве массивов. Аналогично, если вы хотите выполнить map над опциональным значением, но ваша функция преобразования также возвращает опциональный результат, вы получите дважды вложенный опционал. Примером этого является ситуация, когда вы хотите получить первый элемент массива строк в виде числа, используя first на массиве, а затем map, чтобы преобразовать его в число:
let stringNumbers = ["1", "2", "3", "foo"]
let x = stringNumbers.first.map { Int($0) } // Optional(Optional(1))
Проблема в том, что поскольку map возвращает опционал (возможно, first был равен nil), а Int(someString) также возвращает опционал (строка может не быть целым числом), тип x будет Int??.
flatMap вместо этого сгладит результат в один опционал. В результате y будет иметь тип Int?:
let y = stringNumbers.first.flatMap { Int($0) } // Optional(1)
Вместо этого вы могли бы написать это с помощью if let, потому что значения, которые будут связаны позже, могут быть вычислены из более ранних:
if let a = stringNumbers.first, let b = Int(a) {
print(b)
} // 1
Это показывает, что flatMap и if let очень похожи. Ранее в этой главе мы видели пример, который использует множественное выражение if let, и мы можем переписать его, используя map и flatMap вместо этого:
let urlString = "https://www.objc.io/logo.png"
let view = URL(string: urlString)
.flatMap { try? Data(contentsOf: $0) }
.flatMap { UIImage(data: $0) }
.map { UIImageView(image: $0) }
if let view = view {
PlaygroundPage.current.liveView = view
}
Опциональная цепочка также аналогична flatMap: i?.advance(by: 1) по сути эквивалентно i.flatMap { $0.advance(by: 1) }.
Поскольку мы показали, что множественное выражение if let эквивалентно flatMap, мы могли бы реализовать одно через другое:
extension Optional {
func flatMap<U>(transform: (Wrapped) -> U?) -> U? {
if let value = self, let transformed = transform(value) {
return transformed
}
return nil
}
}
Фильтрация nil с помощью compactMap Link to heading
Если у вас есть последовательность, и она содержит опциональные значения, вы можете не обращать внимания на значения nil. На самом деле, вы можете просто захотеть игнорировать их. Предположим, вы хотите обработать только числа в массиве строк. Это легко сделать в цикле for, используя сопоставление с образцом для опционалов:
let numbers = ["1", "2", "3", "foo"]
var sum = 0
for case let i? in numbers.map({ Int($0) }) {
sum += i
}
sum // 6
Вы также можете использовать оператор ??, чтобы заменить значения nil на нули:
numbers.map { Int($0) }.reduce(0) { $0 + ($1 ?? 0) } // 6
Но на самом деле вы просто хотите версию map, которая фильтрует nil и распаковывает ненулевые значения. Введите compactMap из стандартной библиотеки, который делает именно это:
numbers.compactMap { Int($0) }.reduce(0, +) // 6
Чтобы определить свою собственную версию compactMap, мы сначала применяем map ко всему массиву, затем фильтруем ненулевые значения и, наконец, распаковываем каждый элемент:
extension Sequence {
func compactMap<B>(_ transform: (Element) -> B?) -> [B] {
return lazy.map(transform).filter { $0 != nil }.map { $0! }
}
}
В реализации мы используем lazy, чтобы отложить создание массива до последнего момента. Это, возможно, микрооптимизация, но она может быть полезной для больших последовательностей. Использование lazy экономит выделение нескольких промежуточных массивов. Однако стандартная библиотека не делает этого в своей реализации compactMap. В главе о протоколах коллекций мы рассмотрим ленивые последовательности и коллекции более подробно.
Equating Optionals Link to heading
Часто вам не важно, является ли значение nil или нет — важно только, содержит ли оно определенное значение:
let regex = "^Hello$"
// ...
if regex.first == "^" {
// Совпадение только с началом строки.
}
В этом случае не имеет значения, является ли значение nil или нет — если строка пуста, первый символ не может быть кареткой, поэтому вы не хотите выполнять этот блок. Но вы все равно хотите защиту и простоту от first. Альтернатива, если
!regex.isEmpty && regex[regex.startIndex] == "^",
ужасна.
Код выше зависит от двух вещей, чтобы работать. Во-первых, Optional соответствует Equatable, но только если его обернутый тип также соответствует Equatable:
extension Optional: Equatable where Wrapped: Equatable {
static func** ==(lhs: Wrapped?, rhs: Wrapped?) -> Bool {
switch (lhs, rhs) {
case ( nil , nil ): return true
case let (x?, y?): return x == y
case ( _ ?, nil ), ( nil , _ ?): return false
}
}
}
При сравнении двух опционалов есть четыре возможности: они оба nil, или у них обоих есть значение, или одно из них nil. Switch исчерпывающе проверяет все четыре возможности (поэтому нет необходимости в условии по умолчанию). Он определяет, что два nil равны друг другу, nil никогда не равен ненулевому значению, и два ненулевых значения равны, если их развернутые значения равны.
Во-вторых, обратите внимание, что нам не нужно было писать следующее:
if regex.first == Optional("^") { // или: == .some("^")
// Совпадение только с началом строки.
}
Это потому, что всякий раз, когда у вас есть ненулевое значение, Swift всегда будет готов преобразовать его в опциональное значение, чтобы типы совпадали.
Это неявное преобразование невероятно полезно для написания ясного и компактного кода. Предположим, что такого преобразования не было, но чтобы сделать все красиво для вызывающего, вам нужна версия ==, которая работала бы между опциональными и ненулевыми типами. Вам пришлось бы написать три отдельные версии:
// Оба опциональные.
func == <T: Equatable>(lhs: T?, rhs: T?) -> Bool
// lhs ненулевое.
func == <T: Equatable>(lhs: T, rhs: T?) -> Bool
// rhs ненулевое.
func == <T: Equatable>(lhs: T?, rhs: T) -> Bool
Но вместо этого нужна только первая версия, и компилятор преобразует в опционалы, где это необходимо.
На самом деле, мы полагались на эту функцию на протяжении всей книги. Например, когда мы реализовали Optional.map, мы преобразовали внутреннее значение и вернули его. Но возвращаемое значение map — это Optional. Компилятор автоматически преобразовал значение для нас — нам не нужно было писать
return Optional(transform(value)).
Код Swift постоянно полагается на это неявное преобразование. Например, поиск по ключу в словаре возвращает опциональное значение (ключ может отсутствовать). Но он также принимает опциональное значение при присвоении — подпрограммы должны как принимать, так и возвращать один и тот же тип. Без неявного преобразования вам пришлось бы писать
myDict["someKey"] = Optional(someValue).
Кстати, если вы задаетесь вопросом, что происходит со словарями с присвоением по ключу, когда вы присваиваете значение nil, ответ заключается в том, что ключ удаляется. Это может быть полезно, но также означает, что вам нужно быть немного осторожным при работе со словарем с опциональным типом значения. Рассмотрим этот словарь:
var dictWithNils: [String: Int?] = [
"one": 1,
"two": 2,
"none": nil
]
Словарь имеет три ключа, и один из них имеет значение nil. Предположим, мы хотели бы установить значение ключа “two” в nil также. Это не сработает:
dictWithNils["two"] = nil
dictWithNils // ["one": Optional(1), "none": nil]
Вместо этого он удалит ключ “two”.
Чтобы изменить значение для ключа, вам нужно будет написать одно из следующих (все они работают, так что выберите то, что вам кажется более ясным):
dictWithNils["two"] = Optional( nil )
dictWithNils["two"] = .some( nil )
dictWithNils["two"]? = nil
dictWithNils // ["one": Optional(1), "two": nil, "none": nil]
Обратите внимание, что третья версия выше немного отличается от других двух. Она работает, потому что ключ “two” уже есть в словаре, поэтому используется опциональная цепочка для установки его значения, если оно успешно получено. Теперь попробуйте это с ключом, который отсутствует:
dictWithNils["three"]? = nil
dictWithNils.index(forKey: "three") // nil
Вы можете видеть, что ничего не будет обновлено/вставлено.
Сравнение опционалов Link to heading
Подобно оператору ==, ранее существовали реализации операторов <, >, <= и >= для опционалов. В Swift 3.0 эти операторы сравнения были удалены для опционалов, поскольку они могут легко приводить к неожиданным результатам.
Например, nil < .some(_) вернет true. В сочетании с функциями высшего порядка или цепочками опционалов это может быть очень неожиданно. Рассмотрим следующий (устаревший) пример:
let temps = ["-459.67", "98.6", "0", "warm"]
let belowFreezing = temps.filter { Double($0) < 0 }
Поскольку Double(“warm”) вернет nil, а nil был определен как меньше 0, он был бы включен в температуры ниже нуля. Это действительно неожиданно.
Если вам нужны отношения неравенства между опционалами, теперь вам нужно сначала развернуть значения и тем самым явно решить, как следует обрабатывать nil значения.
Когда использовать принудительное извлечение Link to heading
Учитывая все эти техники аккуратного извлечения опционалов, когда следует использовать !, оператор принудительного извлечения? В интернете можно встретить множество мнений на этот счет, включая «никогда», «когда это делает код более понятным» и «когда вы не можете этого избежать». Мы предлагаем следующее правило, которое охватывает большинство из них:
Используйте !, когда вы настолько уверены, что значение не будет nil, что хотите, чтобы ваша программа завершилась с ошибкой, если это когда-либо произойдет.
В качестве примера возьмем реализацию compactMap из вышеуказанного текста:
extension Sequence {
func compactMap<B>(_ transform: (Element) -> B?) -> [B] {
return lazy.map(transform).filter { $0 != nil }.map { $0! }
}
}
Здесь нет никакой возможности, что $0! внутри map когда-либо вернет nil, так как все nil элементы были отфильтрованы на предыдущем шаге. Эта функция, безусловно, могла бы быть написана так, чтобы исключить оператор принудительного извлечения, перебирая массив и добавляя ненулевые значения в новый массив. Но версия с filter/map более чистая и, вероятно, более понятная, поэтому использование ! оправдано.
Тем не менее, такие случаи довольно редки. Если вы полностью овладели всеми техниками извлечения, описанными в этой главе, скорее всего, есть лучший способ, чем принудительное извлечение. Каждый раз, когда вы тянетесь за !, стоит сделать шаг назад и задуматься, действительно ли нет другого варианта.
В качестве другого примера рассмотрим следующий код, который извлекает все ключи из словаря с значениями, соответствующими определенному условию:
let ages = [
"Tim": 53, "Angela": 54, "Craig": 44,
"Jony": 47, "Chris": 37, "Michael": 34,
]
ages.keys
.filter { name in ages[name]! < 50 }
.sorted()
// ["Chris", "Craig", "Jony", "Michael"]
Снова ! здесь абсолютно безопасен — поскольку все ключи пришли из словаря, нет никакой возможности, что ключ может отсутствовать в словаре.
Но вы также можете переписать это выражение, чтобы полностью исключить необходимость в принудительном извлечении. Используя тот факт, что словари представляют собой последовательности пар ключ-значение, вы можете просто отфильтровать эту последовательность, а затем применить map, чтобы удалить значение:
ages.filter { (_, age) in age < 50 }
.map { (name, _) in name }
.sorted()
// ["Chris", "Craig", "Jony", "Michael"]
Эта версия даже может иметь потенциальную выгоду по производительности, так как избегает ненужных обращений к ключам.
Тем не менее, иногда жизнь подбрасывает вам опционал, и вы точно знаете, что он не равен nil. Вы настолько уверены в этом, что предпочли бы, чтобы ваша программа завершилась с ошибкой, чем продолжала работу, потому что столкновение с nil значением означало бы наличие очень неприятной ошибки в вашей логике. Лучше поймать ошибку, чем продолжать в таких обстоятельствах, поэтому ! действует как комбинированный оператор извлечения или ошибки в одном удобном символе. Этот подход часто является более разумным решением, чем просто использование операторов цепочки nil или объединения, чтобы замести теоретически невозможные ситуации под ковер.
Улучшение Force-UnwrapErrorMessages Link to heading
Тем не менее, даже когда вы принудительно извлекаете значение из опционала, у вас есть варианты, помимо использования оператора !. Когда ваша программа выдает ошибку, вы не получаете много информации о том, почему, в журнале вывода.
Скорее всего, вы оставите комментарий о том, почему вы оправданы в принудительном извлечении. Так почему бы не сделать так, чтобы этот комментарий также служил сообщением об ошибке? Вот оператор, !!; он сочетает извлечение с предоставлением более описательного сообщения об ошибке, которое будет записано в журнал при выходе приложения:
infix operator !!
func !! <T>(wrapped: T?, failureText: @autoclosure () -> String) -> T {
if let x = wrapped { return x }
fatalError(failureText())
}
Теперь вы можете написать более описательное сообщение об ошибке, включая значение, которое вы ожидали, чтобы можно было извлечь:
let s = "foo"
let i = Int(s) !! "Ожидалось целое число, получено \"\(s)\""
Утверждения в отладочных сборках Link to heading
Тем не менее, выбор возможности аварийного завершения даже в релизных сборках — довольно смелый шаг. Часто вы можете предпочесть использовать утверждения во время отладки и тестирования, но в производственной среде вы бы заменили их на допустимое значение по умолчанию — возможно, на ноль или пустой массив.
Вводим оператор интерробанг, !?. Мы определяем этот оператор для утверждения при неудачных распаковках, а также для замены значения по умолчанию, когда утверждение не срабатывает в режиме релиза:
infix operator !?
func !?<T: ExpressibleByIntegerLiteral>(wrapped: T?, failureText: @autoclosure () -> String) -> T {
assert(wrapped != nil, failureText())
return wrapped ?? 0
}
Теперь следующее утверждение будет работать во время отладки, но вернет 0 в релизе:
let s = "20"
let i = Int(s) !? "Ожидалось целое число, получено \"\(s)\""
Перегрузка для других протоколов, поддерживающих литералы, позволяет охватить широкий спектр типов, которые могут иметь значение по умолчанию:
func !?<T: ExpressibleByArrayLiteral>(wrapped: T?, failureText: @autoclosure () -> String) -> T {
assert(wrapped != nil, failureText())
return wrapped ?? []
}
func !?<T: ExpressibleByStringLiteral>(wrapped: T?, failureText: @autoclosure () -> String) -> T {
assert(wrapped != nil, failureText())
return wrapped ?? ""
}
А для случаев, когда вы хотите предоставить другое явное значение по умолчанию или для нестандартных типов, вы можете определить версию, которая принимает пару — значение по умолчанию и текст ошибки:
func !?<T>(wrapped: T?, nilDefault: @autoclosure () -> (value: T, text: String)) -> T {
assert(wrapped != nil, nilDefault().text)
return wrapped ?? nilDefault().value
}
// Утверждает в отладке, возвращает 5 в релизе.
Int(s) !? (5, "Ожидалось целое число")
Поскольку опционально цепочечные вызовы методов, возвращающих Void, возвращают Void?, вы также можете написать не обобщенную версию для обнаружения, когда опциональная цепочка достигает nil, что приводит к бездействию:
func !?(wrapped: ()?, failureText: @autoclosure () -> String) {
assert(wrapped != nil, failureText())
}
var output: String? = nil
output?.write("что-то") !? "Не ожидал цепочку nil здесь"
Существует три способа остановить выполнение. Первый вариант, fatalError, принимает сообщение и останавливает выполнение без условий. Второй вариант, assert, проверяет условие и сообщение и останавливает выполнение, если условие оценивается как false. В релизных сборках assert удаляется — условие не проверяется (и выполнение никогда не останавливается). Третий вариант — precondition, который имеет тот же интерфейс, что и assert, но не удаляется из релизных сборок, поэтому, если условие оценивается как false, выполнение останавливается.
Неявно распакованные опционалы Link to heading
Не делайте ошибок: неявно распакованные опционалы — это типы, отмеченные восклицательным знаком, такие как UIView!, — это все еще опционалы, хотя и такие, которые автоматически распаковываются при каждом их использовании. Теперь, когда мы знаем, что принудительное распаковка приведет к сбою вашего приложения, если они когда-либо будут равны nil, зачем же их использовать? На самом деле, есть две причины.
Причина 1: Временно, потому что вы вызываете код на Objective-C, который не был проверен на наличие нулевых значений, или потому что вы обращаетесь к библиотеке C, которая не имеет аннотаций, специфичных для Swift.
Единственная причина, по которой неявно распакованные опционалы вообще существуют, — это упрощение взаимодействия с Objective-C и C. Конечно, в первый день, когда вы начинаете писать Swift против существующей кодовой базы на Objective-C, любой метод Objective-C, который возвращает ссылку, будет преобразован в неявно распакованный опционал. Поскольку на протяжении большей части времени существования Objective-C не было способа указать, что ссылка может быть нулевой, не оставалось другого выбора, кроме как предполагать, что любой вызов, возвращающий ссылку, может вернуть nil. Но немногие API Objective-C на самом деле возвращают нулевые ссылки, поэтому было бы невероятно неудобно автоматически представлять их как опционалы. Поскольку все привыкли иметь дело с миром объектов Objective-C, где “может быть нулевым”, неявно распакованные опционалы стали разумным компромиссом.
Таким образом, вы увидите их в неаудированном мостовом коде Objective-C. Но вы никогда не должны видеть чистый нативный API Swift, возвращающий неявный опционал (или передающий его в обратный вызов).
Причина 2: Потому что значение является nil очень короткое время, в четко определенный период времени, а затем никогда больше не становится nil.
Наиболее распространенный сценарий — это инициализация в два этапа. К тому времени, когда ваш класс готов к использованию, все неявно распакованные опционалы будут иметь значение. Именно поэтому Xcode/Interface Builder использует их в жизненном цикле контроллера представления: в Cocoa и Cocoa Touch контроллеры представления создают свои представления лениво, поэтому существует временное окно — после инициализации контроллера представления, но до загрузки его представления — когда объекты представления, на которые ссылается контроллер представления, еще не были созданы.
ImplicitOptionalBehavior Link to heading
Хотя неявно распакованные опционалы обычно ведут себя как значения, не являющиеся опциональными, вы все равно можете использовать большинство техник распаковки, чтобы безопасно обрабатывать их как опционалы — цепочки, объединение с nil, if let, map или просто сравнение с nil работают одинаково:
var s: String! = "Hello"
s?.isEmpty // Optional(false)
if let s = s { print(s) } // Hello
s = nil
s ?? "Goodbye" // Goodbye
Резюме Link to heading
Опционалы считаются одной из самых больших особенностей Swift для написания более безопасного кода, и мы с этим определенно согласны. Однако, если задуматься, настоящим прорывом являются не опционалы, а не-опционалы. Почти каждый современный язык программирования имеет концепцию “null” или “nil”; то, чего большинству из них не хватает, так это возможности объявить значение как “никогда не nil”. Или, альтернативно, некоторые типы (например, не-классовые типы в Objective-C или Java) “всегда не nil”, заставляя разработчиков придумывать магические значения для представления отсутствия значения.
API, входные и выходные данные которых тщательно спроектированы с учетом опционалов, более выразительны и проще в использовании; меньше необходимости обращаться к документации, поскольку типы несут больше информации.
Все техники распаковки, которые мы продемонстрировали в этой главе, являются попыткой Swift сделать переход между двумя мирами опциональных и не-опциональных значений как можно менее болезненным. Какой метод использовать, часто является вопросом личных предпочтений.
Функции Link to heading
4 Link to heading
Обзор Link to heading
Перед тем как перейти к этой главе, давайте подведем итоги некоторых основных моментов, касающихся функций. Если вы уже знакомы с функциями первого класса, можете смело перейти к следующему разделу. Но если вы хоть немного не уверены в них, прочитайте то, что ниже.
Чтобы понять функции и замыкания в Swift, вам действительно нужно понять три вещи, примерно в следующем порядке важности:
- Функции могут быть присвоены переменным и переданы в другие функции в качестве аргументов, так же как и Int или String.
- Функции могут захватывать переменные, которые существуют вне их локальной области видимости.
- Существует два способа создания функций — либо с помощью ключевого слова
func, либо с помощью{}. Swift называет последнее выражениями замыкания.
Иногда люди, новички в теме замыканий, подходят к этому в обратном порядке и могут пропустить один из этих пунктов, или же они путают термины “замыкание” и “выражение замыкания” — и это может вызвать много путаницы. Это трехногий табурет, и если вы пропустите один из трех пунктов выше, вы упадете, когда попытаетесь на него сесть.
1. Функции могут быть присвоены переменным и передаваться внутрь и наружу Link to heading
Другие функции как аргументы. Link to heading
В Swift, как и во многих современных языках, функции рассматриваются как «объекты первого класса». Вы можете присваивать функции переменным, а также передавать их в другие функции для последующего вызова. Это самое важное, что нужно понять. Понять это для функционального программирования подобно тому, как понять указатели в C. Если вы не совсем усвоили эту часть, все остальное будет просто шумом.
Давайте начнем с функции, которая просто выводит целое число:
func printInt(i: Int) {
print("Вы передали \(i).")
}
Чтобы присвоить функцию переменной funVar, мы используем имя функции в качестве значения. Обратите внимание на отсутствие скобок после имени функции:
let funVar = printInt
Теперь мы можем вызвать функцию printInt, используя переменную funVar. Обратите внимание на использование скобок после имени переменной:
funVar(2) // Вы передали 2.
Также стоит отметить, что мы не должны включать метку аргумента в вызов funVar, в то время как вызовы printInt требуют метку аргумента, как в printInt(i: 2). Swift позволяет использовать метки аргументов только в объявлениях функций; метки не включаются в тип функции. Это означает, что в настоящее время вы не можете присваивать метки аргументов переменной типа функции, хотя это, вероятно, изменится в будущих версиях Swift.
Мы также можем написать функцию, которая принимает функцию в качестве аргумента:
func useFunction(function: (Int) -> ()) {
function(3)
}
useFunction(function: printInt) // Вы передали 3.
useFunction(function: funVar) // Вы передали 3.
Почему возможность обращаться с функциями таким образом так важна? Потому что это позволяет нам легко писать «функции высшего порядка», которые принимают функции в качестве аргументов и применяют их полезным образом, как мы видели в главе о встроенных коллекциях.
Функции также могут возвращать другие функции:
func returnFunc() -> (Int) -> String {
func innerFunc(i: Int) -> String {
return "Вы передали \(i)."
}
return innerFunc
}
let myFunc = returnFunc()
myFunc(3) // Вы передали 3.
2. Функции могут захватывать переменные, которые существуют вне их локальной области Link to heading
Область видимости. Link to heading
Когда функция ссылается на переменные вне своей области видимости, эти переменные захватываются и остаются доступными даже после того, как они в противном случае вышли бы из области видимости и были бы уничтожены. Чтобы это продемонстрировать, давайте снова рассмотрим нашу функцию returnFunc, но добавим счетчик, который увеличивается каждый раз, когда мы ее вызываем:
func makeCounter() -> (Int) -> String {
var counter = 0
func innerFunc(i: Int) -> String {
counter += i // counter захватывается
return "Текущая сумма: \(counter)"
}
return innerFunc
}
Обычно переменная counter, будучи локальной переменной makeCounter, вышла бы из области видимости сразу после оператора return, и она была бы уничтожена. Вместо этого, поскольку она захвачена innerFunc, среда выполнения Swift будет поддерживать ее в живых до тех пор, пока функция, которая ее захватила, не будет уничтожена. Мы можем вызывать внутреннюю функцию несколько раз, и мы увидим, что текущая сумма увеличивается:
let f = makeCounter()
f(3) // Текущая сумма: 3
f(4) // Текущая сумма: 7
Если мы снова вызовем makeCounter(), будет создана новая переменная counter, которая будет захвачена:
let g = makeCounter()
g(2) // Текущая сумма: 2
g(2) // Текущая сумма: 4
Это не влияет на нашу первую функцию, которая все еще имеет свою собственную захваченную версию counter:
f(2) // Текущая сумма: 9
Думайте об этих функциях в сочетании с их захваченными переменными как о чем-то подобном экземплярам классов с одним методом (функцией) и некоторыми переменными-членами (захваченными переменными). В терминологии программирования комбинация функции и окружения захваченных переменных называется замыканием. Таким образом, f и g выше являются примерами замыканий, потому что они захватывают и используют не локальную переменную (counter), которая была объявлена вне их.
3. Функции могут быть объявлены с использованием синтаксиса {} для замыкания Link to heading
Выражения. Link to heading
В Swift вы можете определять функции двумя способами. Один из них — с помощью ключевого слова func. Другой способ — использовать выражение замыкания. Рассмотрим эту простую функцию для удвоения числа:
func doubler(i: Int) -> Int {
return i * 2
}
[1, 2, 3, 4].map(doubler) // [2, 4, 6, 8]
А вот та же функция, написанная с использованием синтаксиса выражения замыкания. Как и раньше, мы можем передать ее в map:
let doublerAlt = { (i: Int) -> Int in return i * 2 }
[1, 2, 3, 4].map(doublerAlt) // [2, 4, 6, 8]
Функции, объявленные как выражения замыкания, можно рассматривать как литералы функций так же, как 1 и “hello” являются литералами для целых чисел и строк. Они также анонимны — у них нет имени, в отличие от функций, объявленных с помощью ключевого слова func. Единственный способ их использования — это присвоение их переменной при создании (как мы делаем здесь с doubler), или передача их в другую функцию или метод.
Существует третий способ использования анонимных функций: вы можете вызывать функцию непосредственно в строке, которая определяет ее. Это может быть полезно для определения свойств, инициализация которых требует больше одной строки. Мы увидим пример этого в главе о свойствах.
Замыкание, объявленное с использованием выражения замыкания, и то, что было объявлено ранее с помощью ключевого слова func, полностью эквивалентны, за исключением различий в их обработке меток аргументов, о которых мы упоминали выше. Они даже существуют в одном и том же “пространстве имен”, в отличие от некоторых языков.
Почему же синтаксис {} полезен? Почему бы не использовать func каждый раз? Ну, это может быть гораздо более компактно, особенно при написании быстрых функций для передачи в другие функции, такие как map. Вот наш пример с map для удвоения, написанный в гораздо более короткой форме:
[1, 2, 3].map { $0 * 2 } // [2, 4, 6]
Это выглядит очень по-другому, потому что мы использовали несколько возможностей Swift, чтобы сделать код более лаконичным. Вот они, по одному:
- Если вы передаете замыкание в качестве аргумента и это все, что вам нужно, нет необходимости сначала сохранять его в локальной переменной. Подумайте об этом как о передаче числового выражения, такого как
5 * i, в функцию, которая принимаетIntв качестве параметра. - Если компилятор может вывести тип из контекста, вам не нужно его указывать. В нашем примере функция, переданная в
map, принимаетInt(выведенный из типа элементов массива) и возвращаетInt(выведенный из типа выражения умножения). - Если тело выражения замыкания содержит только одно выражение, оно автоматически вернет значение этого выражения, и вы можете опустить
return. - Swift автоматически предоставляет короткие имена для аргументов функции —
$0для первого,$1для второго и т.д. - Если последний аргумент функции — это выражение замыкания, вы можете вынести выражение за скобки вызова функции. Этот синтаксис “замыкания на конце” удобен, если у вас есть многострочное выражение замыкания, так как оно больше похоже на обычное определение функции или другой блочный оператор, такой как
if expr {}. Начиная с Swift 5.3, поддерживаются даже несколько замыканий на конце. - Наконец, если функция не имеет аргументов, кроме выражения замыкания, вы можете полностью опустить скобки после имени функции.
Используя каждое из этих правил, мы можем свести выражение ниже к форме, показанной выше:
/*_*/ [1, 2, 3].map( { (i: Int) -> Int in return i * 2 } )
/*_*/ [1, 2, 3].map( { i in return i * 2 } )
/*_*/ [1, 2, 3].map( { i in i * 2 } )
/*_*/ [1, 2, 3].map( { $0 * 2 } )
/*_*/ [1, 2, 3].map() { $0 * 2 }
/*_*/ [1, 2, 3].map { $0 * 2 }
Если вы новичок в синтаксисе Swift и функциональном программировании в целом, эти компактные объявления функций могут показаться пугающими в начале. Но по мере того, как вы будете более уверенно чувствовать себя с синтаксисом и стилем функционального программирования, они начнут казаться более естественными, и вы оцените возможность убрать лишние детали, чтобы яснее видеть, что делает код. Как только вы привыкнете читать код, написанный таким образом, его будет даже легче воспринимать на глаз, чем эквивалентный код, написанный с использованием обычного цикла for.
Иногда Swift нуждается в помощи с выводом типов. И иногда вы можете сделать что-то неправильно, и типы не будут такими, какими вы думаете, что они должны быть. Если вы когда-либо получите загадочную ошибку при попытке предоставить выражение замыкания, хорошей идеей будет записать полную форму (первую версию выше), с указанием типов. Во многих случаях это поможет прояснить, где происходит ошибка. Как только у вас будет компилируемая длинная форма, убирайте типы один за другим, пока компилятор не начнет жаловаться. И если ошибка была вашей, вы исправите свой код в процессе.
Swift также будет настаивать на том, чтобы вы были более явными иногда. Например, вы не можете полностью игнорировать входные параметры. Предположим, вы хотите массив случайных чисел. Быстрый способ сделать это — замапировать диапазон с функцией, которая просто генерирует случайные числа. Тем не менее, вы должны предоставить аргумент. Вы можете использовать _ в таких случаях, чтобы указать компилятору, что вы признаете, что есть аргумент, но вам не важно, что это:
(0..<3).map { _ in Int.random(in: 1..<100) } // [26, 57, 48]
Когда вам нужно явно указать типы переменных, вам не нужно делать это внутри выражения замыкания. Например, попробуйте определить isEven без указания типов:
let isEven = { $0 % 2 == 0 }
В приведенном выше примере тип isEven выводится как (Int) -> Bool так же, как let i = 1 выводится как Int — потому что Int является типом по умолчанию для целочисленных литералов.
Это связано с типом-алиасом IntegerLiteralType в стандартной библиотеке:
protocol ExpressibleByIntegerLiteral {
associatedtype IntegerLiteralType
/// Создать экземпляр, инициализированный значением `value`.
init(integerLiteral value: IntegerLiteralType)
}
/// Тип по умолчанию для иначе неконтролируемого целочисленного литерала.
typealias IntegerLiteralType = Int
Если бы вы определили свой собственный тип-алиас, он бы переопределил тип по умолчанию и изменил это поведение:
typealias IntegerLiteralType = UInt32
let i = 1 // i будет типа UInt32.
Это, вероятно, плохая идея.
Если, однако, вам нужна версия isEven для другого типа, вы могли бы указать аргумент и возвращаемое значение внутри выражения замыкания:
let isEvenAlt = { (i: Int8) -> Bool in i % 2 == 0 }
Но вы также можете предоставить контекст извне замыкания:
let isEvenAlt2: (Int8) -> Bool = { $0 % 2 == 0 }
let isEvenAlt3 = { $0 % 2 == 0 } as (Int8) -> Bool
Поскольку выражения замыкания чаще всего используются в контексте существующих входных или выходных типов, добавление явного типа не так часто необходимо, но полезно знать, что вы можете это сделать.
Конечно, было бы гораздо лучше определить обобщенную версию isEven, которая работает с любым целым числом как вычисляемое свойство:
extension BinaryInteger {
var isEven: Bool { return self % 2 == 0 }
}
В качестве альтернативы, мы могли бы выбрать определение варианта isEven для всех типов Integer как свободной функции:
func isEven<T: BinaryInteger>(_ i: T) -> Bool {
return i % 2 == 0
}
Если вы хотите присвоить эту свободную функцию переменной, это также тот случай, когда вам нужно будет зафиксировать, с какими конкретными типами она работает. Переменная не может хранить обобщенную функцию — только конкретную:
let int8IsEven: (Int8) -> Bool = isEven
Последний момент о наименовании. Важно помнить, что функции, объявленные с помощью func, могут быть замыканиями, так же как и те, которые объявлены с помощью {}. Помните, что замыкание — это функция, объединенная с любыми захваченными переменными. Хотя функции, созданные с помощью {}, называются выражениями замыкания, люди часто называют этот синтаксис просто замыканиями. Но не путайте и не думайте, что функции, объявленные с помощью синтаксиса выражения замыкания, отличаются от других функций — это не так. Все они являются функциями, и все они могут быть замыканиями.
Гибкость через функции Link to heading
В главе о встроенных коллекциях мы говорили о параметризации поведения, передавая функции в качестве аргументов. Давайте рассмотрим еще один пример этого: сортировку.
Сортировка коллекции в Swift проста:
let myArray = [3, 1, 2]
myArray.sorted() // [1, 2, 3]
Существует четыре метода сортировки: немутирующий вариант sorted(by:), и мутирующий sort(by:), два из которых по умолчанию сортируют сопоставимые элементы в порядке возрастания и не принимают аргументов. В самом распространенном случае sorted() — это все, что вам нужно. А если вы хотите отсортировать в другом порядке, просто передайте функцию:
myArray.sorted(by: >) // [3, 2, 1]
Вы также можете передать функцию, если ваши элементы не соответствуют Comparable, но имеют оператор <, как это делают кортежи:
var numberStrings = [(2, "two"), (1, "one"), (3, "three")]
numberStrings.sort(by: <)
numberStrings // [(1, "one"), (2, "two"), (3, "three")]
(Предложение автоматически сделать кортежи совместимыми со стандартными протоколами, такими как Comparable, SE-0283, было принято в 2020 году, но еще не реализовано на момент Swift 5.5.)
Или вы можете предоставить более сложную функцию, если хотите отсортировать по каким-то произвольным критериям:
let animals = ["elephant", "zebra", "dog"]
animals.sorted { lhs, rhs in
let l = lhs.reversed()
let r = rhs.reversed()
return l.lexicographicallyPrecedes(r)
}
// ["zebra", "dog", "elephant"]
Именно эта последняя возможность — возможность использовать любую функцию сравнения для сортировки коллекции — делает сортировку в Swift такой мощной.
Однако что, если мы захотим сортировать по нескольким критериям? Например, рассмотрим структуру Person, которую мы хотим отсортировать по фамилии, а затем по имени, если фамилии равны. В Objective-C это делалось с помощью NSSortDescriptor. Хотя NSSortDescriptor (и его современный вариант, SortDescriptor) гибок и мощен, он работает только с NSObject. Это ограничение существует, потому что он использует систему выполнения Objective-C. В этом разделе мы используем функции высшего порядка, чтобы реализовать свой собственный SortDescriptor, который будет столь же гибким и мощным.
Мы начинаем с определения типа Person:
struct Person {
let first: String
let last: String
let yearOfBirth: Int
}
Давайте также определим массив людей с разными именами и годами рождения:
let people = [
Person(first: "Emily", last: "Young", yearOfBirth: 2002),
Person(first: "David", last: "Gray", yearOfBirth: 1991),
Person(first: "Robert", last: "Barnes", yearOfBirth: 1985),
Person(first: "Ava", last: "Barnes", yearOfBirth: 2000),
Person(first: "Joanne", last: "Miller", yearOfBirth: 1994),
Person(first: "Ava", last: "Barnes", yearOfBirth: 1998),
]
Мы хотим отсортировать этот массив сначала по фамилии, затем по имени, и наконец по году рождения. Порядок должен учитывать настройки локали пользователя. Сначала мы отсортируем только по одному ключу, фамилии:
people.sorted { p1, p2 in
p1.last.localizedStandardCompare(p2.last) == .orderedAscending
}
Если мы хотим сравнить по фамилии, а затем по имени, это уже становится гораздо сложнее:
people.sorted { p1, p2 in
switch p1.last.localizedStandardCompare(p2.last) {
case .orderedAscending:
return true
case .orderedDescending:
return false
case .orderedSame:
return p1.first.localizedStandardCompare(p2.first) == .orderedAscending
}
}
Функции как данные Link to heading
Вместо того чтобы писать еще более сложную функцию, чтобы включить год рождения, мы можем сделать шаг назад и попытаться ввести абстракцию, которая описывает упорядочение значений. Методы стандартной библиотеки sort(by:) и sorted(by:) используют функцию сравнения, которая принимает два объекта и возвращает true, если они упорядочены правильно. Мы могли бы назвать это нашим дескриптором сортировки, определив обобщенный тип-алиас для него:
typealias SortDescriptor<Root> = (Root, Root) -> Bool
Другой альтернативой является определение обертки-структуры вокруг этой функции. Преимущество оборачивания функции в структуру заключается в том, что мы можем определить несколько инициализаторов и методов экземпляра и сделать их легкими для обнаружения через автозаполнение кода. Обернуть функцию в структуру или нет — это в основном вопрос личных предпочтений, но в этом случае это делает результирующий API более естественным для Swift:
struct SortDescriptor<Root> {
var areInIncreasingOrder: (Root, Root) -> Bool
}
В качестве примера мы могли бы определить дескриптор сортировки, который сравнивает два значения Person по году рождения, или дескриптор сортировки, который сортирует по фамилии:
let sortByYear: SortDescriptor<Person> = .init { $0.yearOfBirth < $1.yearOfBirth }
let sortByLastName: SortDescriptor<Person> = .init {
$0.last.localizedStandardCompare($1.last) == .orderedAscending
}
Вместо того чтобы писать дескрипторы сортировки вручную, мы можем написать функцию, которая их генерирует. Это не очень удобно, что нам нужно писать одно и то же свойство дважды: в sortByLastName мы могли бы легко допустить ошибку и случайно сравнить $0.last с $1.first. Кроме того, утомительно писать эти дескрипторы сортировки; чтобы отсортировать по имени, вероятно, проще всего скопировать и вставить определение sortByLastName и изменить его. Сначала давайте упростим создание дескриптора сортировки, который работает для любого свойства, которое является Comparable:
extension SortDescriptor {
init <Value: Comparable>( _ key: @escaping (Root) -> Value) {
self.areInIncreasingOrder = { key($0) < key($1) }
}
}
Функция key описывает, как углубиться в элемент типа Root и извлечь значение типа Value, которое имеет отношение к одному конкретному шагу сортировки. Она имеет много общего с ключевыми путями Swift, и именно поэтому мы заимствовали наименования обобщенных параметров — Root и Value — из типа KeyPath. Позже в этой главе мы обсудим, как переписать дескрипторы сортировки, используя ключевые пути.
Теперь мы можем определить наш дескриптор sortByYear, используя новый инициализатор:
let sortByYearAlt: SortDescriptor<Person> = .init { $0.yearOfBirth }
Аналогично, мы можем создать инициализатор для случая, когда у нас есть функция с той же формой, что и localizedStandardCompare, и другие методы Foundation, которые возвращают ComparisonResult. Если мы сделаем часть String обобщенной, наш инициализатор будет выглядеть так:
extension SortDescriptor {
init <Value>( _ key: @escaping (Root) -> Value,
by compare: @escaping (Value) -> (Value) -> ComparisonResult) {
self.areInIncreasingOrder = {
compare(key($0))(key($1)) == .orderedAscending
}
}
}
Если вы посмотрите на тип выражения String.localizedStandardCompare, вы заметите, что это (String) -> (String) -> ComparisonResult. Что здесь происходит? Внутри экземплярные методы моделируются как функции, которые, принимая экземпляр, возвращают другую функцию, которая затем работает с этим экземпляром. someString.localizedStandardCompare на самом деле просто другой способ записи String.localizedStandardCompare(someString) — оба выражения возвращают функцию типа (String) -> ComparisonResult, и эта функция является замыканием, которое захватило someString.
Это позволяет нам записать sortByFirstName очень лаконично:
let sortByFirstName: SortDescriptor<Person> =
.init({ $0.first }, by: String.localizedStandardCompare)
Когда мы хотим сортировать по нескольким свойствам, мы можем объединить два дескриптора сортировки в один. Мы можем сначала сравнить, используя первичный дескриптор сортировки, и если значения не находятся ни в возрастающем, ни в убывающем порядке, мы используем результат второго дескриптора сортировки:
extension SortDescriptor {
func then( _ other: SortDescriptor<Root>) -> SortDescriptor<Root> {
SortDescriptor { x, y in
if areInIncreasingOrder(x, y) { return true }
if areInIncreasingOrder(y, x) { return false }
return other.areInIncreasingOrder(x, y)
}
}
}
Это позволяет нам объединить все три наших дескриптора сортировки в один дескриптор сортировки, который их комбинирует:
let combined = sortByLastName.then(sortByFirstName).then(sortByYear)
people.sorted(by: combined.areInIncreasingOrder)
/*
[Person(first: "Ava", last: "Barnes", yearOfBirth: 1998),
Person(first: "Ava", last: "Barnes", yearOfBirth: 2000),
Person(first: "Robert", last: "Barnes", yearOfBirth: 1985),
Person(first: "David", last: "Gray", yearOfBirth: 1991),
Person(first: "Joanne", last: "Miller", yearOfBirth: 1994),
Person(first: "Emily", last: "Young", yearOfBirth: 2002)]
*/
Хотя наше решение еще не так выразительно, как дескрипторы сортировки Foundation, оно работает со всеми значениями в Swift, а не только с NSObject. Кроме того, поскольку мы не полагаемся на программирование во время выполнения, компилятор может оптимизировать наш код гораздо лучше.
Одним из недостатков подхода на основе функций является то, что функции являются непрозрачными. Мы можем взять NSSortDescriptor и вывести его в консоль, и мы получим некоторую информацию о дескрипторе сортировки: ключевой путь, имя селектора и порядок сортировки. Мы даже можем сериализовать и десериализовать NSSortDescriptor, используя NSSecureCoding. Наш подход на основе функций не может этого сделать.
Тем не менее, подход использования функций как данных — хранения их в массивах и построения этих массивов во время выполнения — открывает новый уровень динамического поведения, и это один из способов, с помощью которого статически типизированный язык, ориентированный на компиляцию, такой как Swift, может воспроизводить некоторые динамические поведения языков, таких как Objective-C или Ruby.
Мы также увидели полезность написания функций, которые комбинируют другие функции, что является одним из строительных блоков функционального программирования. Например, наш метод then взял два дескриптора сортировки и объединил их в один дескриптор сортировки. Это очень мощная техника с множеством различных применений.
Функции как делегаты Link to heading
Делегаты. Они повсюду. В головы программистов на Objective-C (и Java) вбивается следующее сообщение: используйте протоколы (интерфейсы) для обратных вызовов. Вы определяете протокол, ваш объект реализует этот протокол, и он регистрирует себя в качестве вашего делегата, чтобы получать обратные вызовы.
Если протокол делегата содержит только один метод, вы можете механически заменить свойство, хранящее объект делегата, на то, которое хранит функцию обратного вызова напрямую. Однако есть ряд компромиссов, которые следует учитывать.
Делегаты, стиль Cocoa Link to heading
Начнем с создания протокола так же, как это делает Cocoa, определяя свои бесчисленные протоколы делегатов. Большинство программистов, пришедших из Objective-C, писали код подобным образом много раз:
protocol AlertViewDelegate: AnyObject {
func buttonTapped(atIndex: Int)
}
Протокол AlertViewDelegate определен как протокол, предназначенный только для классов (наследуясь от AnyObject), потому что мы хотим, чтобы наш класс AlertView хранил слабую ссылку на делегата. Таким образом, нам не нужно беспокоиться о циклах ссылок. AlertView никогда не будет сильно удерживать своего делегата, поэтому даже если делегат (прямо или косвенно) имеет сильную ссылку на AlertView, все будет в порядке. Если делегат будет деинициализирован, свойство делегата автоматически станет nil:
class AlertView {
var buttons: [String]
weak var delegate: AlertViewDelegate?
init(buttons: [String] = ["OK", "Cancel"]) {
self.buttons = buttons
}
func fire() {
delegate?.buttonTapped(atIndex: 1)
}
}
Этот паттерн очень хорошо работает, когда мы имеем дело с классами. Например, предположим, что у нас есть класс ViewController, который инициализирует AlertView и устанавливает себя в качестве делегата. Поскольку делегат помечен как weak, нам не нужно беспокоиться о циклах ссылок:
class ViewController: AlertViewDelegate {
let alert: AlertView
init() {
alert = AlertView(buttons: ["OK", "Cancel"])
alert.delegate = self
}
func buttonTapped(atIndex index: Int) {
print("Button tapped: \(index)")
}
}
Общепринятой практикой является всегда помечать свойства делегатов как weak. Эта конвенция упрощает понимание управления памятью, поскольку классы, реализующие протокол делегата, не должны беспокоиться о создании цикла ссылок.
Функции вместо делегатов Link to heading
Если протокол делегата определяет только один метод, мы можем заменить свойство делегата на свойство, которое напрямую хранит функцию обратного вызова. В нашем случае это может быть необязательное свойство buttonTapped, которое по умолчанию равно nil:
class AlertView {
var buttons: [String]
var buttonTapped: (( _ buttonIndex: Int) -> ())?
init(buttons: [String] = ["OK", "Cancel"]) {
self.buttons = buttons
}
func fire() {
buttonTapped?(1)
}
}
Запись (_ buttonIndex: Int) -> () для типа функции выглядит немного странно, потому что внутреннее имя buttonIndex не имеет значения в остальной части кода. Мы упоминали выше, что типы функций, к сожалению, не могут иметь метки параметров; однако они могут иметь явную пустую метку параметра, объединенную с внутренним именем аргумента. Это официально одобренный обходной путь, чтобы дать параметрам в типах функций метки для целей документации, пока Swift не поддерживает лучший способ.
Теперь мы можем создать структуру логгера, а затем создать экземпляр AlertView и переменную логгера:
struct TapLogger {
var taps: [Int] = []
mutating func logTap(index: Int) {
taps.append(index)
}
}
let alert = AlertView()
var logger = TapLogger()
Однако мы не можем просто присвоить метод logTap свойству buttonTapped. Компилятор Swift сообщает нам, что “частичное применение ‘изменяющего’ метода не допускается”:
alert.buttonTapped = logger.logTap // Ошибка
В приведенном выше коде неясно, что должно произойти при присвоении. Логгер копируется? Или buttonTapped должен изменять оригинальную переменную (т.е. логгер захватывается)?
Чтобы это работало, нам нужно обернуть правую часть присвоения в замыкание. Это имеет преимущество в том, что теперь очень ясно, что мы захватываем оригинальную переменную логгера (а не значение), и что мы ее изменяем:
alert.buttonTapped = { logger.logTap(index: $0) }
В качестве дополнительного преимущества, именование теперь разъединено: свойство обратного вызова называется buttonTapped, но функция, которая его реализует, называется logTap. Вместо метода мы также можем указать анонимную функцию:
alert.buttonTapped = { print("Button \($0) was tapped") }
При комбинировании обратных вызовов с классами есть некоторые предостережения. Вернемся к нашему примеру контроллера представления. В его инициализаторе, вместо того чтобы назначать себя делегатом AlertView, контроллер представления теперь может назначить свой метод buttonTapped в качестве обработчика обратного вызова AlertView:
class ViewController {
let alert: AlertView
init() {
alert = AlertView(buttons: ["OK", "Cancel"])
alert.buttonTapped = self.buttonTapped(atIndex:)
}
func buttonTapped(atIndex index: Int) {
print("Button tapped: \(index)")
}
}
Строка alert.buttonTapped = self.buttonTapped(atIndex:) выглядит как невинное присвоение, но будьте осторожны: мы только что создали цикл ссылок! Каждая ссылка на метод экземпляра объекта (например, self.buttonTapped в примере) неявно захватывает объект. Чтобы понять, почему это должно быть так, рассмотрим точку зрения AlertView: когда AlertView вызывает функцию обратного вызова, которая хранится в его свойстве buttonTapped, функция должна каким-то образом “знать”, метод какого объекта она должна вызывать — недостаточно просто хранить ссылку на ViewController.buttonTapped(atIndex:), не зная экземпляра.
Мы могли бы сократить self.buttonTapped(atIndex:) до self.buttonTapped или просто buttonTapped; все три ссылаются на одну и ту же функцию. Метки параметров могут быть опущены, если это не создает неоднозначностей.
Чтобы избежать сильной ссылки, часто необходимо обернуть вызов метода в другое замыкание, которое захватывает объект слабо:
alert.buttonTapped = { [weak self] index in
self?.buttonTapped(atIndex: index)
}
Таким образом, у AlertView не будет сильной ссылки на контроллер представления. Если мы можем гарантировать, что время жизни AlertView связано с контроллером представления, другой вариант — использовать unowned вместо weak. При использовании weak, если AlertView переживет контроллер представления, self будет nil внутри замыкания, когда функция будет вызвана.
Как мы видели, существуют определенные компромиссы между протоколами и функциями обратного вызова. Протокол добавляет некоторую многословность, но протокол, основанный только на классах, с слабым делегатом устраняет необходимость беспокоиться о создании циклов ссылок. Замена делегата на функцию добавляет много гибкости и позволяет использовать структуры и анонимные функции. Однако при работе с классами нужно быть осторожным, чтобы не создать цикл ссылок.
Кроме того, когда вам нужно несколько функций обратного вызова, которые тесно связаны (например, при предоставлении данных для таблицы), может быть полезно держать их сгруппированными в протоколе, а не иметь отдельные обратные вызовы. С другой стороны, при использовании протокола один тип должен реализовать все методы.
Чтобы отменить регистрацию делегата или функции обратного вызова, мы можем установить его в nil. Что насчет случаев, когда наш тип хранит массив делегатов или обратных вызовов? С делегатами на основе классов мы можем удалить объект из списка делегатов. С функциями обратного вызова это не так просто; нам нужно будет добавить дополнительную инфраструктуру для отмены регистрации, потому что функции не могут быть сравнены.
Параметры inout и изменяющие методы Link to heading
Знак “&”, который мы используем перед аргументом inout в Swift, может создать у вас впечатление — особенно если у вас есть опыт работы с C или C++ — что параметры inout по сути являются передачей по ссылке. Но это не так. inout — это передача по значению с последующим копированием обратно, а не передача по ссылке. Как говорится в книге The Swift Programming Language:
Параметр inout имеет значение, которое передается в функцию, модифицируется функцией и возвращается из функции для замены оригинального значения.
Чтобы понять, какие выражения могут быть переданы в качестве параметра inout, нам нужно провести различие между l-значениями и r-значениями. L-значение описывает местоположение в памяти. L-значение — это сокращение от “left value” (левое значение), потому что l-значения — это выражения, которые могут появляться с левой стороны оператора присваивания. Например, array[0] является l-значением, так как оно описывает местоположение в памяти первого элемента в массиве. R-значение описывает значение. 2 + 2 является r-значением, так как оно описывает значение 4. Вы не можете поставить 2 + 2 или 4 на левую сторону оператора присваивания.
Для параметров inout вы можете передавать только l-значения, потому что не имеет смысла изменять r-значение. Когда вы работаете с параметрами inout в обычных функциях и методах, вам нужно явно указывать их: каждое l-значение должно быть предварено знаком &. Например, когда мы вызываем функцию increment (которая принимает inout Int), мы можем передать переменную, предварив ее амперсандом:
func increment(value: inout Int) {
value += 1
}
var i = 0
increment(value: &i)
Если мы определяем переменную с помощью let, мы не можем использовать ее как l-значение. Это имеет смысл, потому что нам не разрешается изменять переменные let; мы можем использовать только “изменяемые” l-значения:
let y: Int = 0
increment(value: &y) // Ошибка
Кроме переменных, несколько других вещей также являются l-значениями. Например, мы также можем передать индекс массива (если массив определен с помощью var):
var array = [0, 1, 2]
increment(value: &array[0])
array // [1, 1, 2]
На самом деле, это работает с каждым индексом (включая ваши собственные пользовательские индексы), при условии, что у них определены как get, так и set. Точно так же мы можем использовать свойства как l-значения, но опять же, только если у них определены как get, так и set:
struct Point {
var x: Int
var y: Int
}
var point = Point(x: 0, y: 0)
increment(value: &point.x)
point // Point(x: 1, y: 0)
Если свойство является только для чтения (то есть доступен только get), мы не можем использовать его как параметр inout:
extension Point {
var squaredDistance: Int {
return x * x + y * y
}
}
increment(value: &point.squaredDistance) // Ошибка
Операторы также могут принимать значение inout, но для простоты они не требуют амперсанда при вызове; мы просто указываем l-значение. Например, давайте добавим обратно оператор постфиксного инкремента, который был удален в Swift 3:
postfix func ++(x: inout Int) {
x += 1
}
point.x++
point // Point(x: 2, y: 0)
Изменяющий оператор может даже быть объединен с опциональной цепочкой. Здесь мы связываем операцию инкремента с доступом к индексу словаря:
var dictionary = ["one": 1]
dictionary["one"]?++
dictionary["one"] // Optional(2)
Обратите внимание, что оператор ++ не будет выполнен, если поиск ключа вернет nil.
Компилятор может оптимизировать переменную inout, чтобы передавать ее по ссылке, а не копировать. Однако в документации явно указано, что не следует полагаться на это поведение.
Мы вернемся к inout в следующей главе о структурах и классах, где мы рассмотрим сходства между изменяющими методами и функциями, которые принимают параметр inout.
Вложенные функции и inout Link to heading
Вы можете использовать параметр inout внутри вложенных функций, и Swift гарантирует, что ваше использование безопасно. Например, вы можете определить вложенную функцию (либо с помощью func, либо с помощью выражения замыкания) и безопасно изменять параметр inout:
func incrementTenTimes(value: inout Int) {
func inc() {
value += 1
}
for _ in 0..<10 {
inc()
}
}
var x = 0
incrementTenTimes(value: &x)
x // 10
Однако вам не разрешается позволять этому параметру inout “выходить за пределы” (мы поговорим об этом позже в этой главе):
func escapeIncrement(value: inout Int) -> () -> () {
func inc() {
value += 1
}
// Ошибка: вложенная функция не может захватывать параметр inout
// и выходить за пределы.
return inc
}
Это имеет смысл, учитывая, что значение inout копируется обратно непосредственно перед возвратом функции. Если бы мы могли каким-то образом изменить его позже, что должно произойти? Должно ли значение быть скопировано обратно в какой-то момент? Что, если источник больше не существует? Проверка этого компилятором критически важна для безопасности.
Когда&Неозначаетinout Link to heading
Говоря об небезопасных функциях, вы должны знать о другом значении & :
преобразование аргумента функции в небезопасный указатель.
Если функция принимает UnsafeMutablePointer в качестве параметра, то вы можете передать в нее переменную, используя &, аналогично тому, как вы бы сделали это с аргументом inout. Но здесь вы действительно передаете по ссылке — фактически по указателю.
Вот функция incref, написанная для принятия небезопасного изменяемого указателя вместо inout:
func incref(pointer: UnsafeMutablePointer<Int>) -> () -> Int {
// Сохраняем копию указателя в замыкании.
return {
pointer.pointee += 1
return pointer.pointee
}
}
Как мы обсудим в последующих главах, массивы Swift неявно преобразуются в указатели, чтобы сделать совместимость с C приятной и безболезненной. Теперь предположим, что вы передаете массив, который выходит из области видимости до того, как вы вызовете результирующую функцию:
let fun: () -> Int
do {
var array = [0]
fun = incref(pointer: &array)
}
/*_*/ fun()
Это открывает захватывающий мир неопределенного поведения. В тестировании приведенный выше код выводил разные значения при каждом запуске: иногда 0, иногда 1, иногда 140362397107840 — и иногда он вызывал сбой во время выполнения.
Мораль здесь такова: знайте, что вы передаете. При добавлении & вы можете вызывать безопасную семантику inout Swift, или вы можете приводить вашу бедную переменную в жестокий мир небезопасных указателей. При работе с небезопасными указателями будьте очень осторожны с временем жизни переменных. Мы подробнее рассмотрим это в главе о совместимости.
Индексы Link to heading
Мы уже видели индексы в стандартной библиотеке. Например, мы можем выполнить поиск в словаре следующим образом: dictionary[key]. Эти индексы представляют собой гибрид функций и свойств, с их собственной специальной синтаксисом. Как и функции, они принимают аргументы. Как и вычисляемые свойства, они могут быть либо только для чтения (используя get), либо для чтения и записи (используя get set). Точно так же, как и обычные функции, мы можем перегружать их, предоставляя несколько вариантов с разными типами — что невозможно сделать со свойствами. Например, массивы по умолчанию имеют два индекса — один для доступа к отдельному элементу и один для получения среза (если быть точным, они объявлены в протоколе Collection):
let fibs = [0, 1, 1, 2, 3, 5]
let first = fibs[0] // 0
fibs[1..<3] // [1, 1]
Пользовательские подскрипты Link to heading
Мы можем добавить поддержку подскриптов для наших собственных типов, а также можем расширить существующие типы новыми перегрузками подскриптов. В качестве примера давайте определим подскрипт для Collection, который принимает список индексов и возвращает массив всех элементов по этим индексам:
extension Collection {
**subscript** (indices indexList: Index...) -> [Element] {
var result: [Element] = []
for index in indexList {
result.append(self[index])
}
return result
}
}
Обратите внимание, как мы использовали явный метку параметра, чтобы различить наш подскрипт от тех, что в стандартной библиотеке. Три точки указывают на то, что indexList является вариативным параметром. Вызывающий код может передать ноль или более значений, разделенных запятыми, указанного типа (в данном случае, типа Index коллекции). Внутри функции параметры становятся доступными в виде массива.
Мы можем использовать новый подскрипт следующим образом:
Array("abcdefghijklmnopqrstuvwxyz")[indices: 7, 4, 11, 11, 14]
// ["h", "e", "l", "l", "o"]
Расширенные подскрипты Link to heading
Подскрипты не ограничены одним параметром. Мы уже видели пример подскрипта, который принимает более одного параметра: подскрипт словаря, который принимает ключ и значение по умолчанию. Ознакомьтесь с его реализацией в исходном коде Swift, если вам это интересно.
Подскрипты также могут быть обобщенными в своих параметрах или возвращаемом типе. Рассмотрим гетерогенный словарь типа [String: Any]:
var japan: [String: Any] = [
"name": "Japan",
"capital": "Tokyo",
"population": 126_440_000,
"coordinates": [
"latitude": 35.0,
"longitude": 139.0
]
]
Если вы хотите изменить вложенное значение в этом словаре, например, широту координат, вы обнаружите, что это не так просто:
// Ошибка: Тип 'Any' не имеет членов подскрипта.
japan["coordinates"]?["latitude"] = 36.0
Хорошо, это понятно. Выражение japan["coordinates"] имеет тип Any?, поэтому вы, вероятно, попытаетесь привести его к словарю перед применением вложенного подскрипта:
// Ошибка: Невозможно присвоить значение неизменяемому выражению.
(japan["coordinates"] as? [String: Double])?["latitude"] = 36.0
Увы, это не только становится быстро неаккуратным, но и не работает. Проблема в том, что вы не можете изменить переменную через приведение типа — выражение japan["coordinates"] as? [String: Double] больше не является lvalue. Вам придется сначала сохранить вложенный словарь в локальную переменную, затем изменить эту переменную, а затем присвоить локальную переменную обратно на верхний уровень ключа.
Мы можем сделать лучше, расширив Dictionary с помощью обобщенного подскрипта, который принимает желаемый целевой тип в качестве второго параметра и пытается выполнить приведение внутри реализации подскрипта:
extension Dictionary {
subscript<Result>(key: Key, as type: Result.Type) -> Result? {
get {
return self[key] as? Result
}
set {
// Удалить существующее значение, если вызывающий передал nil.
guard let value = newValue else {
self[key] = nil
return
}
// Игнорировать, если типы не совпадают.
guard let value2 = value as? Value else {
return
}
self[key] = value2
}
}
}
Поскольку нам больше не нужно выполнять приведение типа для значения, возвращаемого подскриптом, операция изменения проходит через верхний уровень переменной словаря:
japan["coordinates", as: [String: Double].self]?["latitude"] = 36.0
japan["coordinates"] // Optional(["latitude": 36.0, "longitude": 139.0])
Приятно, что обобщенные подскрипты делают это возможным, но вы заметите, что финальный синтаксис в этом примере все еще довольно неаккуратен. Swift, как правило, не очень подходит для работы с гетерогенными коллекциями, такими как этот словарь. В большинстве случаев вам будет лучше определить свои собственные пользовательские типы для ваших данных (например, здесь — структуру Country) и привести эти типы к Codable для преобразования значений в форматы передачи данных и обратно.
Автозакрытия Link to heading
Мы все знакомы с тем, как логический оператор И (&&) оценивает свои аргументы. Сначала он оценивает левый операнд и немедленно возвращает результат, если это значение равно false. Только если левый операнд оценивается как true, происходит оценка правого операнда. В конце концов, если левый операнд равен false, нет никакой возможности, чтобы всё выражение оценивалось как true. Это поведение называется “короткое замыкание”. Например, если мы хотим проверить, выполняется ли условие для первого элемента массива, мы могли бы написать следующий код:
let evens = [2, 4, 6]
if !evens.isEmpty && evens[0] > 10 {
// Выполнить какую-то работу.
}
В приведённом выше фрагменте мы полагаемся на короткое замыкание: поиск в массиве происходит только в том случае, если первое условие выполняется. Без короткого замыкания этот код вызвал бы ошибку при работе с пустым массивом. Лучший способ написать этот конкретный пример — использовать привязку if let:
if let first = evens.first, first > 10 {
// Выполнить какую-то работу.
}
Это ещё одна форма короткого замыкания: второе условие оценивается только в том случае, если первое условие успешно.
В почти всех языках программирования короткое замыкание для операторов && и || встроено в язык. Однако часто невозможно определить свои собственные операторы или функции, которые имеют такое же поведение. Если язык поддерживает функции первого класса, мы можем имитировать короткое замыкание, предоставляя анонимную функцию вместо значения. Например, предположим, что мы хотим определить функцию and в Swift, чтобы она имела такое же поведение, как оператор &&:
func and(_ l: Bool, _ r: () -> Bool) -> Bool {
guard l else { return false }
return r()
}
Функция выше сначала проверяет значение l и возвращает false, если l оценивается как false. Только если l равно true, она возвращает значение, которое возвращает замыкание r. Однако использование этой функции немного менее удобно, чем использование оператора &&, потому что правый операнд теперь должен быть функцией:
if and(!evens.isEmpty, { evens[0] > 10 }) {
// Выполнить какую-то работу.
}
В Swift есть хорошая возможность сделать это более элегантным. Мы можем использовать атрибут @autoclosure, чтобы сказать компилятору, что он должен обернуть определённый аргумент в выражение замыкания. Определение and почти такое же, как и выше, за исключением добавленного аннотации @autoclosure:
func and(_ l: Bool, _ r: @autoclosure () -> Bool) -> Bool {
guard l else { return false }
return r()
}
Однако использование and теперь гораздо проще, так как нам не нужно оборачивать второй параметр в замыкание. Мы можем просто вызывать его так, как если бы он принимал обычный аргумент типа Bool, а компилятор прозрачно оборачивает аргумент в выражение замыкания:
if and(!evens.isEmpty, evens[0] > 10) {
// Выполнить какую-то работу.
}
Это позволяет нам определять собственные функции и операторы с поведением короткого замыкания. Например, операторы, такие как ?? и !? (как определено в главе о Опционалах), теперь легко писать. В стандартной библиотеке функции, такие как assert и precondition, также используют автозакрытия, чтобы оценивать аргументы только тогда, когда это действительно необходимо. Откладывая оценку условий утверждения с места вызова на тело функции assert, эти потенциально дорогостоящие операции могут быть полностью исключены в оптимизированных сборках, где они не нужны.
Автозакрытия также могут быть полезны при написании функций логирования. Например, вот как вы могли бы написать свою собственную функцию log, которая оценивает сообщение лога только в том случае, если условие истинно:
func log(ifFalse condition: Bool,
message: @autoclosure () -> String,
file: String = #fileID, function: String = #function, line: Int = #line) {
guard !condition else { return }
print("Assertion failed: \(message()), \(file):\(function) (line \(line))")
}
Это означает, что вы можете выполнять дорогостоящие вычисления в выражении, которое вы передаёте в качестве аргумента message, не неся затрат на оценку, если значение не используется. Функция log также использует идентификаторы отладки #fileID, #function и #line. Они особенно полезны, когда передаются в качестве значений по умолчанию для функции, потому что они получат значения имени файла, имени функции и номера строки в месте вызова.
Тем не менее, используйте автозакрытия с осторожностью. Их поведение нарушает обычные ожидания — например, если побочный эффект выражения не выполняется, потому что выражение обёрнуто в автозакрытие. Как говорит книга Apple по Swift:
Чрезмерное использование автозакрытий может сделать ваш код трудным для понимания. Контекст и имена функций должны ясно указывать на то, что оценка откладывается.
Аннотация @escaping Link to heading
Вы могли заметить, что компилятор требует от вас явного указания на использование self в некоторых выражениях замыканий, но не в других. Например, нам нужно использовать явное self в обработчике завершения сетевого запроса, в то время как нам не нужно быть явными относительно self в замыканиях, передаваемых в функции map или filter. Разница между ними заключается в том, сохраняется ли замыкание для последующего использования (как в случае сетевого запроса) или используется только синхронно в пределах области видимости функции (как в случае с map и filter).
Если замыкание сохраняется где-то (например, в свойстве) для последующего вызова, оно называется escaping. Напротив, замыкания, которые никогда не покидают локальную область видимости функции, являются non-escaping. В случае escaping замыканий компилятор заставляет нас явно указывать использование self в выражениях замыканий, потому что непреднамеренное сильное захватывание self является одной из самых частых причин циклов ссылок. Non-escaping замыкание не может создать постоянный цикл ссылок, потому что оно автоматически уничтожается, когда функция, в которой оно определено, возвращает результат.
Аргументы замыканий по умолчанию являются non-escaping. Если вы хотите сохранить замыкание для последующего использования, вам нужно пометить аргумент замыкания как @escaping. Компилятор это проверит: если вы не пометите аргумент замыкания как @escaping, он не позволит вам сохранить замыкание (или вернуть его вызывающему, например).
В примере с дескрипторами сортировки было несколько параметров функции, которые требовали атрибута @escaping:
extension SortDescriptor {
init<Value: Comparable>( _ key: @escaping (Root) -> Value) {
self.areInIncreasingOrder = { key($0) < key($1) }
}
}
Обратите внимание, что правило о non-escaping по умолчанию применяется только к параметрам функции, и только для типов функции в непосредственной позиции параметра. Это означает, что сохраненные свойства, имеющие тип функции, всегда являются escaping (что имеет смысл). Удивительно, но то же самое верно для функций, которые используются в качестве параметров, но обернуты в какой-то другой тип, такой как кортеж или опционал. Поскольку замыкание больше не является непосредственным параметром в этом случае, оно автоматически становится escaping. В результате вы не можете написать функцию, которая принимает аргумент функции, где параметр является одновременно опциональным и non-escaping. Во многих ситуациях вы можете избежать того, чтобы делать аргумент опциональным, предоставив значение по умолчанию для замыкания. Если это невозможно, обходным путем является использование перегрузки для написания двух вариантов функции — одного с опциональным (escaping) параметром функции и одного с не опциональным, non-escaping параметром:
func transform( _ input: Int, with f: ((Int) -> Int)?) -> Int {
print("Используется опциональная перегрузка")
guard let f = f else { return input }
return f(input)
}
func transform( _ input: Int, with f: (Int) -> Int) -> Int {
print("Используется не опциональная перегрузка")
return f(input)
}
Таким образом, вызов функции с аргументом nil (или переменной опционального типа) будет использовать опциональный вариант, в то время как передача литерального выражения замыкания вызовет не опциональную, non-escaping перегрузку:
_ = transform(10, with: nil) // Используется опциональная перегрузка
_ = transform(10) { $0 * $0 } // Используется не опциональная перегрузка
withoutActuallyEscaping Link to heading
Вы можете столкнуться с ситуацией, когда вы знаете, что замыкание не покидает контекст, но компилятор не может это доказать, заставляя вас добавлять аннотацию @escaping. Чтобы проиллюстрировать это, давайте рассмотрим пример из документации стандартной библиотеки. Мы пишем собственную реализацию метода allSatisfy для массива, который использует ленивый вид массива (не путать с ленивыми свойствами, которые мы обсуждаем в главе о свойствах) внутри. Затем мы применяем фильтр к ленивому виду и проверяем, прошло ли какое-либо значение через фильтр (т.е. удовлетворяет ли хотя бы один элемент предикату). Наша первая попытка приводит к ошибке компиляции:
extension Array {
func allSatisfy2(_ predicate: (Element) -> Bool) -> Bool {
// Ошибка: Использование замыкания с параметром, не покидающим контекст, 'predicate'
// может позволить ему покинуть контекст.
return self.lazy.filter({ !predicate($0) }).isEmpty
}
}
Мы скажем больше о ленивых коллекциях в главе о протоколах коллекций. На данный момент достаточно знать, что ленивые виды хранят последующие преобразования (например, замыкание, переданное в filter) во внутреннем свойстве, чтобы применить их позже. Это требует, чтобы любое замыкание, которое передается, было помечено как escaping, и это является причиной ошибки, поскольку наш параметр predicate не является escaping.
Мы могли бы исправить это, аннотировав параметр @escaping, но в этом случае мы знаем, что замыкание не покинет контекст, поскольку срок жизни ленивой коллекции связан со сроком жизни функции. Swift предоставляет выход из этой ситуации в виде функции withoutActuallyEscaping. Она позволяет вам передать замыкание, не покидающее контекст, в функцию, которая ожидает замыкание, покидающее контекст. Это компилируется и работает корректно:
extension Array {
func allSatisfy2(_ predicate: (Element) -> Bool) -> Bool {
return withoutActuallyEscaping(predicate) { escapablePredicate in
self.lazy.filter { !escapablePredicate($0) }.isEmpty
}
}
}
let areAllEven = [1, 2, 3, 4].allSatisfy2 { $0 % 2 == 0 } // false
let areAllOneDigit = [1, 2, 3, 4].allSatisfy2 { $0 < 10 } // true
Обратите внимание, что ленивое выполнение не является более эффективным, чем реализация allSatisfy в стандартной библиотеке, которая использует простой цикл for и не требует withoutActuallyEscaping. Ленивое выполнение служит только для демонстрации случая, когда мы знаем, что замыкание не покинет контекст, но компилятор не может это доказать.
Имейте в виду, что вы входите в небезопасную область, используя withoutActuallyEscaping. Позволяя копии замыкания покинуть контекст вызова withoutActuallyEscaping, вы получаете неопределенное поведение.
Строители Результатов Link to heading
Строители результатов — это особый вид функций, которые позволяют нам создавать значения результата из множества операторов, используя лаконичный и выразительный синтаксис. Наиболее ярким примером, и, возможно, мотивацией для этой функции Swift, является синтаксис строителя представлений SwiftUI. Используя строителей представлений, вы можете определить содержимое, например, горизонтального стека представлений, следующим образом:
HStack {
Text("Завершите обновление Advanced Swift")
Spacer()
Button("Завершить") { /* ... */ }
}
Замыкание с правой стороны HStack является функцией строителя результата, хотя оно не аннотировано никаким особым образом. Аннотация — @ViewBuilder — может быть найдена в инициализаторе HStack, который мы немного упростили для этой примера:
struct HStack<Content>: View where Content: View {
public init (
alignment: VerticalAlignment = .center,
spacing: CGFloat? = nil ,
@ViewBuilder content: () -> Content)
// ...
}
В строителе представлений выше мы написали три выражения на отдельных строках. В обычном коде Swift это не имело бы смысла: мы ничего не делаем с значениями этих выражений, и у них также нет побочных эффектов. Однако внутри функции строителя результата компилятор переписывает этот код, чтобы создать составное значение из всех операторов в функции. Для этого он использует статические методы, определенные в соответствующем типе строителя результата, как, например, ViewBuilder в этом примере.
Чтобы переписать приведенный выше пример, компилятор использует статический метод buildBlock на ViewBuilder, который принимает три параметра, соответствующих протоколу View:
**@resultBuilder public struct** ViewBuilder {
// ...
**public static func** buildBlock<C0, C1, C2>( _ c0: C0, _ c1: C1, _ c2: C2)
-> TupleView<(C0, C1, C2)>
where C0: View, C1: View, C2: View
// ...
}
Код, написанный без использования строителей результатов, выглядит следующим образом:
HStack {
return ViewBuilder.buildBlock(
Text("Завершите обновление Advanced Swift"),
Spacer(),
Button("Завершить") { /* ... */ }
)
}
Типы строителей результатов помечены @resultBuilder и могут реализовывать различные статические методы build ..., которые мы рассмотрим более подробно в оставшейся части этого раздела. То, какие из этих методов реализованы, определяет, какие виды выражений и операторов могут быть использованы в функции строителя результата.
BlocksandExpressions Link to heading
Самые базовые методы сборки — это buildBlock и buildExpression. Как и все методы сборки, они являются статическими методами. Тип сборщика просто служит пространством имен и никогда не инстанцируется.
Реализация хотя бы одного метода buildBlock является единственным формальным требованием для типа @resultBuilder. Чаще всего вы будете реализовывать более одной вариации buildBlock, отличающейся количеством и, возможно, типами параметров, так как это позволяет комбинировать несколько частичных результатов в одно итоговое значение. Например, метод buildBlock сборщика представлений имеет варианты от нуля до десяти параметров, что позволяет комбинировать от нуля до десяти представлений в TupleView в функции сборщика представлений.
Методы buildBlock в SwiftUI принимают только значения типа View в качестве аргументов, но это не общее требование для buildBlock, чтобы ограничиваться одним типом аргумента. Мы также можем написать несколько перегрузок с разными типами параметров, хотя реализация buildExpression часто является более элегантным способом поддерживать несколько типов в функции сборщика.
buildExpression не является обязательным для типов сборщиков, но может быть очень полезным для поддержки различных типов выражений. Когда вы реализуете один или несколько вариантов этого метода, Swift применит buildExpression к каждому выражению в функции сборщика сначала, прежде чем передать частичные результаты в buildBlock.
Метод buildExpression принимает один параметр, но вы можете реализовать несколько вариантов для поддержки различных типов параметров. Возвращаемое значение может быть любого типа, при условии, что этот тип поддерживается каким-либо методом buildBlock.
Прежде чем мы рассмотрим другие методы build ..., которые могут быть реализованы для типов сборщиков результатов, мы используем buildBlock и buildExpression и создадим свой собственный тип сборщика результатов. В качестве примера мы реализуем сборщик результатов для построения строк. В своей самой простой форме тип сборщика строк выглядит так:
@resultBuilder
struct StringBuilder {
static func buildBlock() -> String {
""
}
}
Реализуя buildBlock без параметров, мы поддерживаем функции сборки пустых строк, как эта:
@StringBuilder
func build() -> String {
}
Результатом выполнения этой функции является пустая строка. Под капотом компилятор переписывает функцию build на:
func build_rewritten() -> String {
StringBuilder.buildBlock()
}
Чтобы поддержать построение строки из нескольких строк, мы добавляем вариант buildBlock, который принимает переменное количество строковых аргументов (переменный список аргументов также принимает ноль аргументов, так что это может заменить предыдущий метод без параметров):
static func buildBlock(_ strings: String...) -> String {
strings.joined()
}
Переменный параметр охватывает все от нуля до любого количества строковых аргументов с помощью одного метода. Это работает для нашего примера, потому что мы ожидаем, что все аргументы будут одного типа. В отличие от этого, сборщик представлений SwiftUI предоставляет отдельные перегрузки buildBlock для различных количеств параметров и, таким образом, искусственно ограничен десятью представлениями (потому что Apple решила остановиться на этом). SwiftUI делает это таким образом, потому что каждый аргумент может быть разного типа (каждый соответствует View), и фреймворк хочет сохранить эти типы. Мы ожидаем, что будущий релиз Swift поддержит форму вариативных обобщений, что позволит SwiftUI (и другим) предоставить вариативную перегрузку buildBlock, которая принимает обобщенные аргументы.
Теперь мы можем написать функцию сборщика строк, как эту:
@StringBuilder
func greeting() -> String {
"Hello, "
"World!"
}
greeting() // Hello, World!
Swift переводит эту функцию сборщика строк в следующий код:
func greeting_rewritten() -> String {
StringBuilder.buildBlock(
"Hello, ",
"World!"
)
}
Далее мы можем добавить поддержку для других типов — целых чисел, например — реализуя buildExpression:
static func buildExpression(_ s: String) -> String {
s
}
static func buildExpression(_ x: Int) -> String {
"\(x)"
}
Обратите внимание, что мы должны реализовать buildExpression для всех типов, которые мы хотим поддерживать в качестве выражений — не только для тех, для которых нет подходящего метода buildBlock.
Теперь мы можем без проблем смешивать строки и целые числа для построения строкового результата:
let planets = [
"Mercury", "Venus", "Earth", "Mars", "Jupiter",
"Saturn", "Uranus", "Neptune"
]
@StringBuilder
func greetEarth() -> String {
"Hello, Planet "
planets.firstIndex(of: "Earth")!
"!"
}
greetEarth() // Hello, Planet 2!
Вот переписанный код для этого примера:
func greetEarth_rewritten() -> String {
StringBuilder.buildBlock(
StringBuilder.buildExpression("Hello, Planet "),
StringBuilder.buildExpression(planets.firstIndex(of: "Earth")!),
StringBuilder.buildExpression("!")
)
}
Перегрузка методов Builder Link to heading
Как мы видели выше, мы можем написать несколько перегрузок для методов, таких как buildBlock или buildExpression. В основном, мы предоставляем перегрузки для поддержки различных типов в функции сборщика. Например, мы реализовали несколько методов buildExpression с разными типами параметров для поддержки строк и целых чисел в сборщике строк, а SwiftUI реализует несколько вариантов buildBlock для поддержки компоновки различного количества представлений. Однако перегрузки также могут быть использованы для включения некоторых других полезных функций.
Например, по умолчанию невозможно использовать оператор fatalError в функции сборщика, потому что он рассматривается как выражение с типом Never. Реализация варианта buildExpression для типа Never решает эту проблему (к сожалению, это не очень практично, так как компилятор отметит эту реализацию с предупреждением “Will never be executed”, которое вы, возможно, не хотите видеть в своем коде):
static func buildExpression(_ x: Never) -> String {
fatalError()
}
Аналогично, мы не можем писать операторы print в функции сборщика, потому что print имеет тип возвращаемого значения Void. Чтобы разрешить операторы print, мы можем добавить вариант buildExpression, который принимает параметр типа Void, не влияя на результат, который собирается:
static func buildExpression(_ x: Void) -> String {
""
}
Более того, перегрузки buildExpression также могут быть использованы для предоставления более четкой диагностики компилятора. Например, мы можем добавить второй, более общий вариант buildExpression, чтобы предоставить более ясное сообщение об ошибке для значений неподдерживаемых типов:
@available(*, unavailable, message: "String Builders only support string and integer values")
static func buildExpression<A>(_ expression: A) -> String {
""
}
Поскольку существующие перегрузки для String и Int более специфичны, чем эта общая функция, компилятор будет использовать этот “ошибочный” вариант только для типов, которые мы не хотим поддерживать. В этом случае атрибут @available помечает эту реализацию как недоступную и указывает на пользовательское сообщение об ошибке.
Условные конструкции Link to heading
На данный момент наш StringBuilder более или менее представляет собой другую синтаксическую конструкцию для интерполяции строк (о которой мы говорим в главе о строках). Добавив методы buildIf и buildEither к типу сборщика, мы можем расширить его возможности и поддерживать конструкции if, if ... else и switch.
Мы начинаем с реализации метода buildIf, который позволяет поддерживать простые операторы if без ветки else. Параметр buildIf — это необязательное значение, которое будет равно nil, если условие не выполнено:
static func buildIf( _ s: String?) -> String {
s ?? ""
}
Это позволяет нам переписать наш предыдущий пример следующим образом:
@StringBuilder func greet(planet: String) -> String {
"Hello, Planet"
if let idx = planets.firstIndex(of: planet) {
" "
idx
}
"!"
}
greet(planet: "Earth") // Hello, Planet 2!
greet(planet: "Sun") // Hello, Planet!
Теперь, когда мы ввели условия в функцию сборщика, переписанная версия начинает становиться более интересной:
func greet_rewritten(planet: String) -> String {
let v0 = "Hello, Planet"
var v1: String?
if let idx = planets.firstIndex(of: planet) {
v1 = StringBuilder.buildBlock(
StringBuilder.buildExpression(" "),
StringBuilder.buildExpression(idx)
)
}
let v2 = StringBuilder.buildIf(v1)
return StringBuilder.buildBlock(v0, v2)
}
Сначала объявляется необязательная переменная для частичного результата условия (имена переменных — v0, v1 и т. д. — являются общими именами, которые мы выбрали для этого примера). Затем Swift переписывает операторы внутри условия так же, как мы видели ранее: buildExpression вызывается для каждого выражения, а затем buildBlock вызывается со всеми частичными результатами из вызовов buildExpression. Наконец, buildIf вызывается с частичным результатом условия.
Чтобы лучше обрабатывать недопустимые входные данные для функции приветствия, поддержка if ... else была бы очень полезной. Для этого нам нужно реализовать методы сборщика buildEither(first:) и buildEither(second:).
buildEither(first:) будет вызываться с результатом первого ветвления, а buildEither(second:) будет вызываться с результатом второго ветвления. Если есть несколько связанных операторов if ... else, компилятор представляет их как вложенные вызовы buildEither. Например, рассмотрим условие, подобное этому:
if x == 0 {
// ...
} else if x < 10 {
// ...
} else {
// ...
}
Мы можем записать то же самое следующим образом:
if x == 0 {
// ...
} else {
if x < 10 {
// ...
} else {
// ...
}
}
Оба оператора if теперь имеют одну ветку else, и они будут переписаны с использованием первой и второй вариаций buildEither.
Для примера с StringBuilder реализация buildEither крайне проста, так как нам нужно только вернуть частичный результат из первого или второго ветвления:
static func buildEither( fi rst component: String) -> String {
component
}
static func buildEither(second component: String) -> String {
component
}
В других случаях эти две вариации могут быть использованы для различения того, как результат преобразуется в ветвлении if или else, например, чтобы сохранить информацию о том, какая ветка была выполнена.
С добавленной поддержкой if ... else (и switch одновременно) мы можем расширить наш пример:
@StringBuilder func greet2(planet: String) -> String {
"Hello, "
if let idx = planets.firstIndex(of: planet) {
switch idx {
case 2:
"World"
case 1, 3:
"Neighbor"
default:
"planet "
idx + 1
}
} else {
"unknown planet"
}
"!"
}
greet2(planet: "Earth") // Hello, World!
greet2(planet: "Mars") // Hello, Neighbor!
greet2(planet: "Jupiter") // Hello, planet 5!
greet2(planet: "Pluto") // Hello, unknown planet!
Код, как он переписан компилятором Swift для функции приветствия выше, уже становится довольно громоздким:
func greet2_rewritten(planet: String) -> String {
let v0 = StringBuilder.buildExpression("Hello, ")
let v1: String
if let idx = planets.firstIndex(of: planet) {
let v1_0: String
switch idx {
case 2:
v1_0 = StringBuilder.buildEither( fi rst:
StringBuilder.buildBlock(StringBuilder.buildExpression("World"))
)
case 1, 3:
v1_0 = StringBuilder.buildEither(second:
StringBuilder.buildEither( fi rst:
StringBuilder.buildBlock(StringBuilder.buildExpression("Neighbor"))
)
)
default:
let v1_0_0 = StringBuilder.buildExpression("planet")
let v1_0_1 = StringBuilder.buildExpression(idx + 1)
v1_0 = StringBuilder.buildEither(second:
StringBuilder.buildEither(second:
StringBuilder.buildBlock(v1_0_0, v1_0_1)
)
)
}
v1 = StringBuilder.buildEither( fi rst: v1_0)
} else {
v1 = StringBuilder.buildEither(second:
StringBuilder.buildBlock(
StringBuilder.buildExpression("unknown planet")
)
)
}
let v2 = StringBuilder.buildExpression("!")
return StringBuilder.buildBlock(v0, v1, v2)
}
Циклы Link to heading
Есть еще одно выражение, которое мы можем включить в функции построителей результатов: циклы for…in. Чтобы разрешить использование циклов, нам нужно добавить метод buildArray в наш тип построителя:
static func buildArray( _ components: [String]) -> String {
components.joined(separator: "")
}
Это позволяет нам писать циклы следующим образом:
@StringBuilder func greet3(planet: String?) -> String {
"Hello "
if let p = planet {
p
} else {
for p in planets.dropLast() {
"\(p), "
}
"and \(planets.last!)!"
}
}
Вызов функции greet3(planet: nil ) вернет:
// Hello Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune!
Swift берет частичный результат каждой итерации цикла и собирает их в массив. Этот массив затем передается в buildArray, чтобы построить частичный результат для всего цикла:
func greet3_rewritten(planet: String?) -> String {
let v0 = StringBuilder.buildExpression("Hello ")
let v1: String
if let p = planet {
v1 = StringBuilder.buildBlock(StringBuilder.buildExpression(p))
} else {
var v1_0: [String] = []
for p in planets.dropLast() {
let v1_0_0 = StringBuilder.buildBlock(
StringBuilder.buildExpression("\(p), ")
)
v1_0.append(v1_0_0)
}
let v1_1 = StringBuilder.buildArray(v1_0)
let v1_2 = "and \(planets.last!)!"
v1 = StringBuilder.buildBlock(v1_1, v1_2)
}
return StringBuilder.buildBlock(v0, v1)
}
ДругиеМетодыПостроения Link to heading
Типы строителей результатов могут реализовать два дополнительных метода, которые мы можем использовать для преобразования результата, который был построен. Первый метод, buildLimitedAvailability, может быть использован для преобразования типа результата в контексте ограниченной доступности (например, если #available(...)). Например, SwiftUI использует этот метод в своем типе строителя представлений, чтобы обернуть частичный результат из контекста ограниченной доступности в обертку типа, стирающую типы, AnyView. Это необходимо, потому что тип результата контекста ограниченной доступности может содержать типы, которые недоступны вне этого контекста.
Последний метод преобразования в типах строителей результатов — это buildFinalResult: как следует из названия, этот метод применяется к результату всей функции строителя результатов. Один из случаев использования этого метода — скрыть внутренние типы, которые были использованы для построения частичных результатов от внешнего мира.
Например, строитель строк может использовать [String] в качестве внутреннего типа для построения результата, а затем преобразовать массив в строку в buildFinalResult. Это означает, что все методы построения будут иметь тип результата [String], за исключением buildFinalResult, который возвращает финализированную строку:
@resultBuilder
struct StringBuilder {
static func buildBlock(_ x: [String]...) -> [String] { x.flatMap { $0 } }
static func buildIf(_ x: [String]?) -> [String] { x ?? [] }
static func buildExpression(_ x: String) -> [String] { [x] }
static func buildExpression(_ x: Int) -> [String] { ["\(x)"] }
static func buildExpression(_ x: Never) -> [String] {}
static func buildExpression(_ x: Void) -> [String] { [] }
static func buildArray(_ x: [[String]]) -> [String] { x.flatMap { $0 } }
static func buildEither(first x: [String]) -> [String] { x }
static func buildEither(second x: [String]) -> [String] { x }
static func buildFinalResult(_ x: [String]) -> String { x.joined() }
}
UnsupportedStatements Link to heading
Мы видели выше, как вы можете включить определенные виды операторов в функциях построителей результатов, такие как if / if let, if … else, switch и for … in. На момент написания почти все остальные операторы — включая guard, defer, do … catch, break и continue — не поддерживаются.
Резюме Link to heading
Функции являются объектами первого класса в Swift, и обращение с функциями как с данными может сделать наш код более гибким. Мы увидели, как простые функции могут заменить определенные виды программирования во время выполнения и делегаты. Мы рассмотрели изменяющие функции и параметры inout, сабскрипты (которые на самом деле являются особым видом функции), а также атрибуты @autoclosure и @escaping. Наконец, мы рассмотрели строители результатов как особый вид функции, который позволяет нам создавать возвращаемые значения с выразительным и лаконичным синтаксисом. В следующей главе мы рассмотрим тесно связанную тему: свойства. В главах о обобщениях и протоколах мы обсудим больше способов использования функций в Swift для достижения гибкости.
Свойства Link to heading
5 Link to heading
Свойства в Swift бывают двух видов: хранимые свойства и вычисляемые свойства. Хранимые свойства хранят значения, в то время как вычисляемые свойства похожи на функции: они не предоставляют хранилище, а лишь предоставляют способ получить и (опционально) установить значение. В некотором смысле, вы можете рассматривать вычисляемые свойства как методы с другой синтаксисом.
Вы можете думать о свойствах как о переменных, которые определены для типа. Большая часть того, что мы говорим в этой главе, также относится к локальным и глобальным переменным. Переменные могут быть хранимыми или вычисляемыми, иметь наблюдателей за изменениями и использовать обертки свойств. Мы рассматриваем свойства как «особый случай» переменных, а не наоборот.
Существует две важные функции, построенные на основе свойств: ключевые пути и обертки свойств. Ключевые пути — это способ ссылаться на путь свойства, не ссылаясь на значение. Все больше библиотек принимают ключевые пути как способ написания очень лаконичного, обобщенного кода, и мы увидим несколько примеров этого в этой главе. Обертка свойства позволяет вам изменить поведение свойства с очень минимальным синтаксисом. Обертки свойств сыграли важную роль в предоставлении SwiftUI его легковесного синтаксиса.
Давайте рассмотрим различные способы определения свойств. Начнем с структуры, которая представляет GPS-трек. Она хранит все записанные точки в массиве, называемом record, который является хранимым свойством:
import CoreLocation
struct GPSTrack {
var record: [(CLLocation, Date)] = []
}
Если мы хотим сделать свойство record доступным только для чтения снаружи, но с возможностью записи внутри, мы можем использовать модификаторы private(set) или fileprivate(set):
struct GPSTrack {
private(set) var record: [(CLLocation, Date)] = []
}
Чтобы получить все временные метки в GPS-треке, мы создаем вычисляемое свойство:
extension GPSTrack {
/// Возвращает все временные метки для GPS-трека.
/// - Сложность: O(*n*), где *n* — количество записанных точек.
var timestamps: [Date] {
return record.map { $0.1 }
}
}
Поскольку мы не указали сеттер, свойство timestamps является только для чтения. Результат не кэшируется; каждый раз, когда вы обращаетесь к свойству, оно вычисляет результат. Руководство по проектированию API Swift рекомендует документировать сложность каждого вычисляемого свойства, которое не является O(1), поскольку вызывающие могут предположить, что доступ к свойству дешевый.
ChangeObservers Link to heading
Мы также можем реализовать обработчики willSet и didSet для хранимых свойств и переменных, которые будут вызываться каждый раз, когда свойство устанавливается (даже если значение не изменяется). Эти обработчики вызываются непосредственно перед и после того, как новое значение сохраняется, соответственно. Один из полезных случаев — это когда представление (view) должно снова расположить себя в зависимости от определенных свойств. Вызывая setNeedsLayout в didSet, мы можем быть уверены, что это всегда произойдет. (В разделе о обертках свойств мы рассмотрим еще более короткий способ сделать это.)
class MyView: UIView {
var pageSize: CGSize = CGSize(width: 800, height: 600) {
didSet {
self.setNeedsLayout()
}
}
}
Наблюдатели должны быть определены на месте объявления свойства — вы не можете добавить один ретроактивно в расширении. Поэтому они являются инструментом для разработчика типа, а не для пользователя. Обработчики willSet и didSet по сути являются сокращением для определения пары свойств: одного приватного хранимого свойства, которое обеспечивает хранение, и одного публичного вычисляемого свойства, чей сеттер выполняет дополнительную работу до и/или после сохранения значения в хранимом свойстве. Это принципиально отличается от механизма наблюдения за ключ-значением (KVO) в Foundation, который часто используется потребителями объекта для наблюдения за внутренними изменениями, независимо от того, намеревался ли разработчик класса на это.
Тем не менее, вы можете переопределить свойство в подклассе, чтобы добавить наблюдателя. Вот пример:
class Robot {
enum State {
case stopped, movingForward, turningRight, turningLeft
}
var state = State.stopped
}
class ObservableRobot: Robot {
override var state: State {
willSet {
print("Переход от \(state) к \(newValue)")
}
}
}
var robot = ObservableRobot()
robot.state = .movingForward // Переход от stopped к movingForward
Это по-прежнему согласуется с природой наблюдателей изменений как внутренней характеристикой типа. Если бы это не было разрешено, подкласс мог бы достичь того же эффекта, переопределив хранимое свойство с вычисляемым сеттером, который выполняет дополнительную работу.
Разница в использовании отражена в реализации этих функций. KVO использует среду выполнения Objective-C для динамического добавления наблюдателя к сеттеру класса, что было бы невозможно реализовать в текущей версии Swift, особенно для типов значений. Наблюдение за свойствами в Swift является чисто функцией времени компиляции.
LazyStoredProperties Link to heading
Инициализация значения лениво — это такой распространенный паттерн, что Swift имеет специальное ключевое слово lazy для определения ленивых свойств. Обратите внимание, что ленивое свойство всегда должно объявляться как var, потому что его начальное значение может быть установлено только после завершения инициализации. Swift имеет строгое правило, что константы let должны иметь значение до завершения инициализации экземпляра. Модификатор lazy является очень специфической формой мемоизации.
Например, если у нас есть контроллер представления, который отображает GPSTrack, мы можем захотеть иметь предварительное изображение трека. Сделав свойство ленивым, мы можем отложить дорогостоящую генерацию изображения до тех пор, пока свойство не будет доступно в первый раз:
class GPSTrackViewController: UIViewController {
var track: GPSTrack = GPSTrack()
lazy var preview: UIImage = {
for point in track.record {
// Выполнить некоторые дорогостоящие вычисления.
}
return UIImage( /* ... */ )
}()
}
Обратите внимание, как мы определили ленивое свойство: это замыкание, которое возвращает значение, которое мы хотим сохранить — в нашем случае, изображение. Когда свойство впервые запрашивается, замыкание выполняется (обратите внимание на скобки в конце), и его возвращаемое значение сохраняется в свойстве. Это распространенный паттерн для ленивых свойств, которые требуют больше одной строки для инициализации.
Поскольку ленивой переменной требуется память, мы обязаны определить ленивое свойство в определении GPSTrackViewController. В отличие от вычисляемых свойств, хранимые свойства и хранимые ленивые свойства не могут быть определены в расширении. Точно так же, в отличие от вычисляемых свойств, хранимые свойства и хранимые ленивые свойства не пересчитываются каждый раз, когда одно из этих свойств запрашивается. Например, когда свойство track изменяется, preview не будет автоматически пересчитано.
Давайте рассмотрим еще более простой пример, чтобы понять, что происходит. У нас есть структура Point, и мы храним distanceFromOrigin как ленивое вычисляемое свойство:
struct Point {
var x: Double
var y: Double
private(set) lazy var distanceFromOrigin: Double = (x * x + y * y).squareRoot()
init(x: Double, y: Double) {
self.x = x
self.y = y
}
}
Когда мы создаем точку, мы можем получить доступ к свойству distanceFromOrigin, и оно вычислит значение и сохранит его для повторного использования. Однако, если мы затем изменим значение x, это не отразится в distanceFromOrigin:
var point = Point(x: 3, y: 4)
point.distanceFromOrigin // 5.0
point.x += 10
point.distanceFromOrigin // 5.0
Важно быть осведомленным об этом. Один из способов обойти это — пересчитывать distanceFromOrigin в наблюдателях свойств didSet для x и y, но тогда distanceFromOrigin больше не будет ленивым: оно будет вычисляться каждый раз, когда изменяется x или y. Конечно, в этом примере решение простое: мы должны были сделать distanceFromOrigin обычным (не ленивым) вычисляемым свойством с самого начала.
Доступ к ленивому свойству является мутирующей операцией, потому что начальное значение свойства устанавливается при первом доступе. Когда структура содержит ленивое свойство, любой владелец структуры, который получает доступ к ленивому свойству, должен объявить переменную, содержащую структуру, как var, потому что доступ к свойству может потенциально изменить его контейнер. Поэтому это не разрешено:
let immutablePoint = Point(x: 3, y: 4)
immutablePoint.distanceFromOrigin
// Ошибка: Невозможно использовать мутирующий геттер для неизменяемого значения.
Принуждение всех пользователей типа Point, которые хотят получить доступ к ленивому свойству, использовать var, является большим неудобством, что часто делает ленивые свойства плохим выбором для структур.
Кроме того, имейте в виду, что ключевое слово lazy не выполняет никакой синхронизации потоков. Если несколько потоков одновременно обращаются к ленивому свойству до того, как значение было вычислено, возможно, что вычисление может быть выполнено более одного раза, вместе с любыми побочными эффектами, которые может иметь это вычисление.
Обертки свойств Link to heading
Одной из основных причин добавления оберток свойств в Swift, безусловно, стало появление SwiftUI. Однако они обсуждались и до этого, и их полезность выходит за рамки только SwiftUI — как Apple, так и сообщество написали обертки свойств, которые работают в самых разных ситуациях. На самом деле, одной из мотиваций для создания оберток свойств было желание иметь возможность писать ленивые свойства в виде библиотеки, а не использовать встроенную функциональность компилятора.
По сути, обертки свойств позволяют вам изменять поведение объявлений свойств. Например, рассмотрим следующий вид SwiftUI:
struct Toggle: View {
@Binding var isOn: Bool
// ...
var body: some View {
if isOn {
// ...
}
}
}
В приведенном выше коде @Binding является оберткой свойства. Вы можете использовать свойство isOn так же, как и обычное свойство типа Bool: вы можете как получать, так и устанавливать его значение. Тем не менее, поведение отличается: память хранится вне значения Toggle, что делает ее непрозрачной для представления. Вы также можете изменять значение, не находясь в методе с модификацией. Обертки свойств широко используются в SwiftUI, в основном для управления состоянием.
Вы можете использовать обертки свойств для свойств классов и структур, для локальных переменных (но не для глобальных переменных) и в аргументах функций. В конце этого раздела мы обсудим некоторые ограничения оберток свойств более подробно.
Еще одним случаем использования оберток свойств является работа с UIKit и AppKit, где часто бывает необходимо, чтобы изменение свойства инвалидировало часть представления. Вспомните код из предыдущей части этой главы, где мы используем наблюдатель изменений, чтобы инвалидировать макет представления при изменении определенного свойства:
class MyView: UIView {
var pageSize: CGSize = CGSize(width: 800, height: 600) {
didSet {
self.setNeedsLayout()
}
}
}
Используя обертку свойства @Invalidating, добавленную в iOS 15 (и macOS 12), вы теперь можете написать этот код следующим образом:
class MyView: UIView {
@Invalidating(.layout) var pageSize: CGSize = CGSize(width: 800, height: 600)
}
Обертка свойства @Invalidating хранит размер внутренне, и всякий раз, когда он изменяется, она вызывает setNeedsLayout от вашего имени. Как только у вас есть много свойств, которые все инвалидируют разные части вашего представления (макет, ограничения, отображение и т. д.), использование этой обертки свойства может значительно упростить ваш код. Обратите внимание, что вы все еще можете использовать willSet и didSet с обертками свойств; они работают так же, как и наблюдатели изменений для обычных свойств.
За пределами представлений обертки свойств также полезны. Существуют обертки свойств, написанные сообществом, для настройки способа кодирования или декодирования типа через Codable, обертки свойств вокруг UserDefaults и обертки свойств, чтобы сделать реактивное программирование проще (например, @Published из Combine).
Использование Link to heading
Обертки свойств — это простая синтаксическая особенность: вы можете использовать Binding и Invalidating без оберток свойств. Вот пример Binding из предыдущего раздела, но переписанный без оберток свойств:
struct Toggle: View {
var isOn: Binding<Bool>
// ...
var body: some View {
if isOn.wrappedValue {
// ...
}
}
}
На самом деле, когда вы используете обертки свойств, компилятор преобразует ваш код аналогичным образом. Чтобы проиллюстрировать это, давайте создадим простую обертку свойства для хранения значений в коробке:
@propertyWrapper
class Box<A> {
var wrappedValue: A
init (wrappedValue: A) {
self .wrappedValue = wrappedValue
}
}
Тип Box полезен, когда вам нужна изменяемая переменная, которая разделяется (вы можете передать один и тот же экземпляр Box в несколько мест) или когда вам нужна изменяемая переменная в области, которая не позволяет мутации (например, вы можете изменить значение внутри Box, даже когда находитесь внутри метода, не допускающего мутацию). Приведенное выше определение позволяет нам использовать Box следующим образом:
struct Checkbox {
@Box var isOn: Bool = **false**
func didTap() {
isOn.toggle()
}
}
Чтобы понять, что происходит, давайте посмотрим на тот же код после преобразования компилятором:
struct Checkbox {
private var _isOn: Box<Bool> = Box(wrappedValue: **false**)
var isOn: Bool {
get { _isOn.wrappedValue }
nonmutating set { _isOn.wrappedValue = newValue }
}
func didTap() {
isOn.toggle()
}
}
Для каждой переменной, помеченной оберткой свойства, компилятор генерирует фактическое хранимое свойство с префиксом подчеркивания. В дополнение к этому генерируется вычисляемое свойство, которое получает доступ к wrappedValue подлежащей обертки свойства. Если свойство инициализируется значением (в приведенном выше примере isOn инициализируется значением false), обертка свойства инициализируется с помощью .init(wrappedValue:).
При определении обертки свойства вы должны предоставить как минимум геттер для wrappedValue. Сеттер является необязательным, и в зависимости от того, присутствует он или нет, для вычисляемого свойства генерируется сеттер. В приведенном выше случае есть сеттер, поэтому для вычисляемого свойства также генерируется сеттер. Поскольку Box является классом, сгенерированный сеттер является немутирующим. Как и сеттер, init(wrappedValue:) также является необязательным, но поскольку мы предоставляем его в Box, мы можем инициализировать наш Box<Bool> с помощью Bool.
Projected Values Link to heading
В SwiftUI обертки свойств, такие как @State и @ObservedObject, используются для определения владения и хранения значения. Например, определение @State var x: Int предоставляет вам хранилище для изменяемой переменной типа Int. Само хранилище управляется SwiftUI. Однако многие компоненты не заботятся о том, где что-то хранится; им просто нужно значение, с которым они могут работать. Например, Toggle нуждается в Bool, который он может читать и записывать, а TextField нуждается в изменяемой строке String. Эти значения предоставляются SwiftUI с помощью обертки свойств Binding, которая по сути является геттером и сеттером для значения, хранящегося вне привязки.
SwiftUI позволяет вам создать привязку из @State (или других оберток свойств, таких как @ObservedObject) с помощью специальной функции обертки свойств, называемой projected value. Это работает так: когда вы реализуете свойство projectedValue, создается дополнительное вычисляемое свойство — с тем же именем, что и определение, но с префиксом $. Другими словами, когда у вас есть свойство foo, определенное с помощью обертки свойств, запись $foo эквивалентна записи foo.projectedValue.
Чтобы увидеть это в действии, мы можем расширить наш Box, чтобы у нас также были ссылки на часть упакованного значения. Мы создадим тип, который работает почти так же, как Binding в SwiftUI. Например, если у нас есть Box с Person структурой, мы могли бы иметь ссылку на свойство name. Box по-прежнему является хранилищем значения Person, но ссылка на имя человека позволяет нам читать и записывать эту часть значения. В качестве первого шага мы можем определить обертку свойств Reference, которая хранит способ получения и установки значения:
@propertyWrapper
class Reference<A> {
private var _get: () -> A
private var _set: (A) -> ()
var wrappedValue: A {
get { _get() }
set { _set(newValue) }
}
init(get: @escaping () -> A, set: @escaping (A) -> ()) {
_get = get
_set = set
}
}
Теперь, как второй шаг, мы можем расширить Box, чтобы иметь projectedValue, в свою очередь создавая Reference<A> из Box<A>:
extension Box {
var projectedValue: Reference<A> {
Reference<A>(get: { self.wrappedValue }, set: { self.wrappedValue = $0 })
}
}
Поскольку мы реализовали projectedValue, мы теперь можем создать Box, содержащий значение Person, создать из него ссылку, используя префикс $, и передать эту ссылку в отдельную функцию или инициализатор. Внутри PersonEditor любые изменения person изменят основное значение внутри Box:
struct Person {
var name: String
}
struct PersonEditor {
@Reference var person: Person
}
func makeEditor() -> PersonEditor {
@Box var person = Person(name: "Chris")
return PersonEditor(person: $person)
}
Проектируемые значения полезны в сочетании с динамическим поиском членов на основе ключевых путей. Например, вместо того чтобы передавать Person в PersonEditor, мы можем захотеть передать только имя человека в TextEditor. В приведенном выше примере мы не можем просто написать $person.name, потому что $person имеет тип Reference<Person>, а не Person. Мы можем исправить это, добавив динамический поиск членов к типу Reference:
@propertyWrapper
@dynamicMemberLookup
class Reference<A> {
// ...
subscript<B>(dynamicMember keyPath: WritableKeyPath<A, B>) -> Reference<B> {
Reference<B>(get: {
self.wrappedValue[keyPath: keyPath]
}) {
self.wrappedValue[keyPath: keyPath] = $0
}
}
}
Это позволяет нам создать Reference<String>, написав $person.name. Синтаксис $foo.prop1.prop2 преобразуется в foo.projectedValue[dynamicMember: \.prop1.prop2].
EnclosingSelf Link to heading
Некоторые обертки свойств работают только тогда, когда у них есть доступ к окружающему объекту. Например, обертка свойства @Published из фреймворка Combine от Apple получает доступ к свойству objectWillChange объекта, который содержит свойство @Published. В приведенном ниже примере изменение a.city вызывает событие через издатель a.objectWillChange:
class Address: ObservableObject {
// ...
@Published var city: String = "Berlin"
}
let a = Address()
a.city = "New York"
Аналогично, в примере с @Invalidating из введения в этот раздел любое изменение свойства, помеченного как @Invalidating, приведет к недействительности окружающего представления. В качестве примера того, как это работает, мы повторно реализуем небольшую часть @Invalidating. Чтобы получить доступ к окружающему экземпляру, нам нужно использовать неофициальный API. После выпуска официальный API может выглядеть иначе, чем описание в этом разделе. Вместо использования wrappedValue нам нужно реализовать статический сабскрипт. Этот сабскрипт принимает окружающий объект в качестве параметра, а также ключевые пути к двум свойствам, которые составляют обертку свойства. Вот пример:
@propertyWrapper
struct InvalidatingLayout<A> {
private var _value: A
// ...
static subscript <T: UIView>(
_enclosingInstance object: T,
wrapped _ : ReferenceWritableKeyPath<T, A>,
storage storage: ReferenceWritableKeyPath<T, Self>) -> A {
get {
object[keyPath: storage]._value
}
set {
object[keyPath: storage]._value = newValue
object.setNeedsLayout()
}
}
}
В приведенном выше коде сабскрипт теперь используется вместо wrappedValue. Внутри сабскрипта у нас есть доступ к представлению (через object) и самой обертке свойства (используя ключевой путь storage вместе с окружающим объектом). Мы можем использовать обертку свойства так же, как обычную обертку свойства:
class AView: UIView {
@InvalidatingLayout var x = 100
}
Учитывая вышеуказанное определение, вот что компилятор синтезирует для нас:
class AView: UIView {
var _x = InvalidatingLayout(wrappedValue: 100)
var x: Int {
get {
InvalidatingLayout[_enclosingInstance: self, wrapped: \.x, storage: \._x]
}
set {
InvalidatingLayout[_enclosingInstance: self, wrapped: \.x, storage: \._x]
= newValue
}
}
}
Наше определение свойства @InvalidatingLayout еще не совсем завершено. В дополнение к сабскрипту мы предоставляем init(wrappedValue:), чтобы разрешить инициализацию с начальным значением. Мы также обязаны реализовать wrappedValue, хотя на самом деле не можем придумать разумную реализацию для сеттера (поскольку нам нужен объект). Тем не менее, мы можем аннотировать свойство как устаревшее:
struct InvalidatingLayout<A> {
// ...
@available (*, unavailable, message: "@InvalidatingLayout доступен только для подклассов UIView")
var wrappedValue: A {
get { fatalError() }
set { fatalError() }
}
init (wrappedValue: A) {
_value = wrappedValue
}
// ...
}
Аннотация для wrappedValue обеспечит, что наша обертка свойства может использоваться только с подклассами UIView; любое другое использование вызовет ошибку компилятора. В SwiftUI вы можете предположить, что @State работает, обращаясь к окружающему self. Это не так, так как окружающий self не является объектом, а является типом значения. Вместо этого память для значения состояния управляется SwiftUI внутренне в зависимости от положения представления в иерархии представлений. Это поведение реализуется с помощью интроспекции.
PropertyWrapperInsandOuts Link to heading
Обертки свойств работают для свойств структур и классов, но не работают внутри перечислений. Это имеет смысл, потому что перечисления не могут иметь хранилище вне случая, а обертка свойства всегда включает синтезированное хранимое свойство. Вы также можете использовать обертки свойств для локальных переменных (например, внутри тела функции), но не для глобальных переменных. Более того, свойства или переменные, определенные с помощью обертки свойства, не могут быть помечены как unowned, weak, lazy или @NSCopying.
Начиная с Swift 5.5, функции также могут принимать аргументы обертки свойств. Например, рассмотрим следующую функцию:
func takesBox(@Box foo: String) {
// ...
}
Чтобы вызвать эту функцию, вы предоставляете строку в качестве параметра — не Box. Компилятор преобразует вышеуказанную функцию во что-то вроде этого:
func takesBox(foo initialValue: String) {
var _foo: Box<String> = Box(wrappedValue: initialValue)
var foo: String {
get { _foo.wrappedValue }
nonmutating set { _foo.wrappedValue = newValue }
}
}
Обратите внимание, что в приведенном выше примере обертка свойства не является частью API функции takesBox. Однако, если у обертки свойства есть инициализатор с именем init(projectedValue:), компилятор также сгенерирует другую версию функции, которая принимает projectedValue. Например, если мы реализуем init(projectedValue:) для Box, вторая версия функции takesBox будет выглядеть так:
func takesBox($foo initialValue: Reference<String>) {
var _foo: Box<String> = Box(projectedValue: initialValue)
var foo: String {
get { _foo.wrappedValue }
nonmutating set { _foo.wrappedValue = newValue }
}
}
Другими словами, когда обертка свойства с init(projectedValue:) используется в функции, она становится частью API функции. Свойства, определенные с использованием обертки свойства в структуре, также являются частью инициализатора с учетом членов. Например, рассмотрим следующее определение структуры:
struct Test {
@Box var name: String
@Reference var street: String
}
Сгенерированный компилятором инициализатор с учетом членов будет:
init(name: String, street: Reference<String>)
Если у обертки свойства есть инициализатор init(wrappedValue:) (например, @Box), инициализатор с учетом членов будет принимать обернутое значение для этого свойства. Если у обертки свойства нет этого инициализатора (например, @Reference), соответствующий аргумент в инициализаторе с учетом членов включает обертку. Вы, конечно, все еще можете написать свой собственный инициализатор, чтобы принимать обернутые или необернутые значения по своему усмотрению.
Обертки свойств также могут быть вложенными. Например, чтобы добавить двойную обертку вокруг свойства, вы можете написать: @Box @Box var name: String. Однако это, похоже, пока не работает с обертками свойств, которые используют экземпляр enclosing self. Более того, обертки свойств, которые полагаются на интроспекцию (например, @State в SwiftUI), обычно будут работать только в том случае, если они являются внешней оберткой.
Ключевые пути Link to heading
Ключевой путь — это невызываемая ссылка на свойство, аналогичная непримененной ссылке на метод. Выражения ключевых путей начинаются с обратной косой черты, например, \String.count. Обратная косая черта необходима для того, чтобы отличить ключевой путь от свойства типа с тем же именем, которое может существовать (предположим, что у String также есть статическое свойство count — тогда String.count вернет значение этого свойства). Вывод типов также работает в выражениях ключевых путей: вы можете опустить имя типа, если компилятор может вывести его из контекста, что оставляет .count.
Как и предполагает название, ключевой путь описывает путь через иерархию типов, начиная с корневого значения. Например, учитывая следующие типы Person и Address, \Person.address.street является ключевым путем, который разрешает улицу проживания человека:
struct Address {
var street: String
var city: String
var zipCode: Int
}
struct Person {
let name: String
var address: Address
}
let streetKeyPath = \Person.address.street
// Swift.WritableKeyPath<Person, Swift.String>
let nameKeyPath = \Person.name // Swift.KeyPath<Person, Swift.String>
Ключевые пути могут состоять из любой комбинации хранимых и вычисляемых свойств, а также операторов опциональной цепочки. Компилятор автоматически генерирует новый [keyPath:] подскрипт для всех типов. Вы используете этот подскрипт, чтобы «вызвать» ключевой путь, т.е. получить доступ к свойству, описанному им, на данном экземпляре. Таким образом, “Hello”[keyPath:.count] эквивалентно “Hello”.count. Или, для нашего текущего примера:
let simpsonResidence = Address(street: "1094 Evergreen Terrace", city: "Springfield", zipCode: 97475)
var lisa = Person(name: "Lisa Simpson", address: simpsonResidence)
lisa[keyPath: nameKeyPath] // Lisa Simpson
Если вы посмотрите на типы наших двух переменных ключевых путей выше, вы заметите, что тип nameKeyPath — это KeyPath<Person,String> (т.е. строго типизированный ключевой путь, который может быть применен к Person и возвращает String), в то время как тип streetKeyPath — это WritableKeyPath. Поскольку все свойства, формирующие этот последний ключевой путь, изменяемы, сам ключевой путь позволяет изменять основное значение:
lisa[keyPath: streetKeyPath] = "742 Evergreen Terrace"
Попытка сделать то же самое с nameKeyPath вызовет ошибку, потому что основное свойство не изменяемо.
Ключевые пути могут не только ссылаться на свойства; мы также можем использовать их для обхода подскриптов. Например, следующий синтаксис может быть использован для извлечения имени второго человека в массиве:
var bart = Person(name: "Bart Simpson", address: simpsonResidence)
let people = [lisa, bart]
people[keyPath: \.[1].name] // Bart Simpson
Тот же синтаксис также может быть использован для включения подскриптов словарей в ключевые пути.
Ключевые пути могут быть смоделированы с помощью функций Link to heading
Ключевой путь, который отображает базовый тип Root на свойство типа Value, очень похож на функцию типа (Root) -> Value — или, для записываемых ключевых путей, на пару функций для получения и установки значения. Основное преимущество ключевых путей по сравнению с такими функциями (помимо синтаксиса) заключается в том, что они являются значениями. Вы можете проверять ключевые пути на равенство и использовать их в качестве ключей словаря (они соответствуют протоколу Hashable), и вы можете быть уверены, что ключевой путь не имеет состояния — в отличие от функций, которые могут захватывать изменяемое состояние. Ничто из этого невозможно с обычными функциями.
Компилятор может автоматически преобразовать выражение ключевого пути в функцию. Например, следующий код является сокращенной записью для people.map { $0.name }:
people.map(\.name) // ["Lisa Simpson", "Bart Simpson"]
Обратите внимание, что это работает только для выражений ключевых путей. Например, следующий код использует выражение ключевого пути для определения ключевого пути, и он не компилируется: /show/
let keyPath = \Person.name
people.map(keyPath)
С выражением ключевого пути компилятор имеет два варианта для выведенного типа: \Person.name может быть либо KeyPath<Person, String>, либо (Person) -> String. Компилятор предпочтет тип ключевого пути, но если это не сработает, он попробует тип функции.
Ключевые пути также могут быть составными, если вы добавите один ключевой путь к другому. Обратите внимание, что типы должны совпадать: если вы начинаете с ключевого пути от A к B, ключевой путь, который вы добавляете, должен иметь корневой тип B, и результирующий ключевой путь будет отображать от A к типу значения добавленного ключевого пути, скажем, C:
// KeyPath<Person, String> + KeyPath<String, Int> = KeyPath<Person, Int>
let nameCountKeyPath = nameKeyPath.appending(path: \.count)
// Swift.KeyPath<Person, Swift.Int>
WritableKeyPaths Link to heading
Записываемый ключевой путь (Writable KeyPath) является специальным; вы можете использовать его для чтения или записи значения. Следовательно, он эквивалентен паре функций: одной для получения свойства ((Root) -> Value), и другой для установки свойства ((inout Root, Value) -> Void). Записываемые ключевые пути захватывают много кода в лаконичном синтаксисе. Сравните streetKeyPath с эквивалентной парой геттера и сеттера:
let streetKeyPath = \Person.address.street
let getStreet: (Person) -> String = { person in
return person.address.street
}
let setStreet: (inout Person, String) -> () = { person, newValue in
person.address.street = newValue
}
// Использование сеттера
lisa[keyPath: streetKeyPath] = "1234 Evergreen Terrace"
setStreet(&lisa, "1234 Evergreen Terrace")
Записываемые ключевые пути бывают в двух формах: WritableKeyPath и ReferenceWritableKeyPath. Второй тип используется с типами, которые имеют семантику ссылок (классы), а первый тип используется со всеми остальными типами. Разница в использовании заключается в том, что WritableKeyPath требует, чтобы корневое значение было изменяемым, тогда как ReferenceWritableKeyPath не требует этого.
Записываемые ключевые пути используются во многих фреймворках, таких как SwiftUI. Например, в SwiftUI есть значение “environment”, которое передается через иерархию представлений. Это значение управляется и распространяется фреймворком, но вы можете изменить его, используя специальные функции, которые требуют WritableKeyPath для изменения части этого значения. Как мы видели в разделе о проекциях значений, записываемые ключевые пути также очень полезны в сочетании с динамическим поиском членов.
The Key Path Hierarchy Link to heading
Существует пять различных типов ключевых путей, каждый из которых добавляет больше точности и функциональности к предыдущему:
→ AnyKeyPath аналогичен функции типа (Any) -> Any? .
→ PartialKeyPath аналогичен функции типа (Source) -> Any? .
→ KeyPath<Source, Target> аналогичен функции типа (Source) -> Target .
→ WritableKeyPath<Source, Target> аналогичен паре функций типа (Source) -> Target и (inout Source, Target) -> () .
→ ReferenceWritableKeyPath<Source, Target> аналогичен паре функций типа (Source) -> Target и (Source, Target) -> () . Вторая функция может обновить значение Source с помощью Target, и она работает только тогда, когда Source является ссылочным типом. Различие между WritableKeyPath и ReferenceWritableKeyPath необходимо, поскольку сеттер для первого должен принимать свой аргумент как параметр inout.
Эта иерархия ключевых путей в настоящее время реализована как иерархия классов. В идеале это должны быть протоколы, но система обобщений Swift не имеет некоторых функций, чтобы сделать это возможным. Иерархия классов намеренно сохраняется закрытой, чтобы облегчить изменение этого в будущих релизах без нарушения существующего кода.
Как мы уже видели ранее, ключевые пути отличаются от функций: они соответствуют Hashable, и в будущем, вероятно, будут соответствовать Codable. Вот почему мы говорим, что AnyKeyPath аналогичен функции типа (Any) -> Any. Хотя мы можем преобразовать ключевой путь в соответствующую функцию(и), мы не всегда можем пойти в обратном направлении.
Сравнение ключевых путей с Objective-C Link to heading
В Foundation и Objective-C ключевые пути моделируются как строки (мы будем называть их ключевыми путями Foundation, чтобы отличать их от ключевых путей Swift). Поскольку ключевые пути Foundation являются строками, они не имеют прикрепленной информации о типе. В этом смысле они похожи на AnyKeyPath. Если ключевой путь Foundation написан с ошибкой или не является корректным, или если типы не совпадают, программа, вероятно, завершится с ошибкой. Директива #keyPath в Swift немного помогает с опечатками; компилятор может проверить, существует ли свойство с указанным именем. Ключевые пути Swift, WritableKeyPath и ReferenceWritableKeyPath корректны по конструкции: их нельзя написать с ошибкой, и они не допускают ошибок типов.
Многие API Cocoa используют (Foundation) ключевые пути, когда функция могла бы быть более подходящей. Это отчасти исторический артефакт: анонимные функции (или блоки, как их называет Objective-C) являются относительно недавним дополнением, а ключевые пути существуют гораздо дольше. Прежде чем блоки были добавлены в Objective-C, было нелегко представить что-то подобное функции {$0.address.street}, кроме как с помощью ключевого пути: “address.street”.
Будущие направления Link to heading
Ключевые пути все еще находятся на стадии активного обсуждения, и, вероятно, в будущем они станут более функциональными. Одной из возможных функций является сериализация через протокол Codable. Это позволит нам сохранять ключевые пути на диске, отправлять их по сети и так далее.
Как только у нас будет доступ к структуре ключевых путей, это откроет возможности для интроспекции. Например, мы могли бы использовать структуру ключевого пути для построения хорошо типизированных запросов к базе данных (существуют открытые проекты, которые уже делают это, но они полагаются на внутренние механизмы, которые могут измениться в будущем). Если типы смогут автоматически предоставлять массив ключевых путей к своим свойствам, это может послужить основой для API рефлексии во время выполнения.
На данный момент ключевые пути очень медленные по сравнению с прямым доступом к свойствам. Это известная проблема. В большинстве случаев ясность ключевых путей важнее их скорости, но когда вы пишете код, чувствительный к производительности, вам следует быть внимательным к этому (например, не создавайте экземпляры ключевых путей в узком цикле).
Резюме Link to heading
Свойства и переменные являются неотъемлемой частью Swift. Хотя хранимые свойства и вычисляемые свойства используют одинаковый синтаксис, они выполняют две совершенно разные функции: хранимые свойства используются для хранения данных, тогда как вычисляемые свойства более похожи на функции.
В значительной степени обертки свойств и ключевые пути являются синтаксическим сахаром. Другими словами, они позволяют вам выразить вещи, которые вы уже могли бы написать ранее, просто с гораздо более коротким синтаксисом. Однако не стоит недооценивать их по этой причине. Чистый синтаксис важен для написания понятного кода.
Обертка свойства может усложнить понимание происходящего, как и любая другая абстракция. Тем не менее, тщательно спроектированная обертка свойства может упростить распространенные шаблоны и стоит дополнительной абстракции. Использование оберток свойств в SwiftUI является хорошим примером этого.
Структуры и Link to heading
Классы Link to heading
6 Link to heading
Когда мы разрабатываем наши типы данных, Swift позволяет нам выбирать между двумя альтернативами, которые на первый взгляд кажутся похожими: структурами и классами. Обе могут иметь хранимые и вычисляемые свойства, а также методы, определенные для них. Более того, не только у обеих есть инициализаторы, но мы можем определять расширения для них, и мы можем приводить их к протоколам. Иногда наш код даже продолжает компилироваться, когда мы меняем ключевое слово class на struct или наоборот. Однако сходства на поверхности обманчивы, так как структуры и классы имеют принципиально разные поведения.
Структуры являются типами значений, в то время как классы являются типами ссылок. Даже если мы не думаем в этих терминах, мы все знакомы с поведением значений и ссылок в нашей повседневной работе. Мы постараемся использовать это неявное понимание в следующем разделе, чтобы прояснить формальное различие между типами значений и типами ссылок в общем, а также между структурами и классами в частности.
Типы значений и ссылочные типы Link to heading
Давайте начнем с рассмотрения одного из самых простых типов: целых чисел. Рассмотрим следующий код:
var a: Int = 3
var b = a
b += 1
Каково значение a сейчас? Вероятно, можно с уверенностью сказать, что мы все ожидаем, что a по-прежнему будет равно 3, даже несмотря на то, что мы увеличили b до 4. Все остальное было бы большим сюрпризом. И это действительно так:
a // 3
b // 4
Это поведение является сутью типов значений: присваивание копирует значение. Другими словами, каждая переменная типа значения хранит свое собственное независимое значение. Если тип ведет себя таким образом, его также говорят, что он имеет семантику значений.
Посмотрев на определение Int в стандартной библиотеке, мы действительно можем увидеть, что это структура (и, следовательно, имеет семантику значений):
public struct Int: FixedWidthInteger, SignedInteger {
...
}
Прежде чем продолжить, давайте сделаем шаг назад и посмотрим на это поведение с более низкоуровневой точки зрения.
Что мы имеем в виду под термином «переменная»? Мы можем сказать, что переменная — это имя для места в памяти, которое содержит значение определенного типа. В приведенном выше примере мы используем имя a, чтобы ссылаться на место в памяти типа Int, в данный момент хранящее значение 3. Вторая переменная, b, является именем для другого места в памяти, также типа Int и содержащего значение 3 после первоначального присваивания. Оператор b += 1 затем берет значение, хранящееся в месте памяти, на которое ссылается b, увеличивает его на единицу и записывает обратно в то же место в памяти. Таким образом, b теперь содержит значение 4. Поскольку оператор инкремента изменяет только значение переменной b, a не затрагивается этим оператором.
Типы значений характеризуются этой прямой связью между переменной и значением: значение (также называемое экземпляром типа значения) находится непосредственно по адресу в памяти, связанному с переменной. Это относится к простым типам значений, таким как целые числа, но также и к более сложным типам, таким как пользовательские структуры с несколькими свойствами (на уровне машинного кода это может не всегда быть верно из-за оптимизаций компилятора, но они невидимы для разработчика, так что наше описание по крайней мере семантически точно).
Далее давайте рассмотрим класс представления в качестве примера типичного ссылочного типа:
var view1 = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
var view2 = view1
view2.frame.origin = CGPoint(x: 50, y: 50)
view1.frame.origin // (50, 50)
view2.frame.origin // (50, 50)
Хотя мы присвоили новое значение origin для view2.frame.origin, мы естественно ожидаем, что frame view1 также изменится. На самом деле, мы ожидаем, что view1 и view2 в определенном смысле являются одним и тем же — они оба представляют одно и то же представление, которое мы видим на экране. Это разговорный способ сказать, что UIView является ссылочным типом, и что переменные view1 и view2 содержат ссылки на один и тот же экземпляр UIView в памяти.
Мы переназначаем переменную view2, как так:
view2 = UILabel()
Когда мы это делаем, view1 по-прежнему ссылается на ранее созданное представление, в то время как view2 теперь ссылается на вновь созданный экземпляр метки. Другими словами, переназначение изменило экземпляр (или объект), на который указывает view2.
Это и есть суть ссылочных типов: переменные не содержат «вещь» саму по себе (например, экземпляр UIView или URLSession), а ссылку на нее. Другие переменные также могут содержать ссылку на тот же основной экземпляр, и экземпляр может быть изменен через любую из его ссылочных переменных. Тип с такими свойствами также говорят, что имеет семантику ссылки.
По сравнению с типами значений, здесь есть еще один уровень косвенности. В то время как переменная типа значения содержит само значение, переменная ссылочного типа содержит ссылку, указывающую на значение где-то еще. Эта косвенность позволяет нам делиться доступом к объекту между различными частями нашей программы.
Давайте рассмотрим различные поведения типов значений и типов ссылок на примере пользовательского типа, который мы определим, начиная с класса:
class** ScoreClass {
var home: Int
var guest: Int
init (home: Int, guest: Int) {
self .home = home
self .guest = guest
}
}
var score1 = ScoreClass(home: 0, guest: 0)
var score2 = score1
score2.guest += 1
score1.guest // 1
Обе переменные, score1 и score2, ссылаются на один и тот же базовый экземпляр Score.
Поэтому изменение счета гостей через score2 изменяет значение, которое мы видим при доступе к счету гостей через score1. Мы также можем передать score2 в функцию, которая выполняет мутацию:
func scoreGuest( _ score: ScoreClass) {
score.guest += 1
}
scoreGuest(score1)
score1.guest // 2
score2.guest // 2
Если мы вместо этого определим тип счета как структуру, поведение изменится:
struct ScoreStruct {
var home: Int
var guest: Int
// Инициализатор с параметрами, сгенерированный компилятором.
}
var score3 = ScoreStruct(home: 0, guest: 0)
var score4 = score3
score4.guest += 1
score3.guest // 0
Как мы видели с целыми числами выше, присвоение структуры другой переменной создает независимую копию значения. Поэтому изменение счета гостей через переменную score4 не влияет на счет гостей в score3.
Используя версию структуры Score, мы не можем определить ту же функцию scoreGuest, что и выше для аналогии класса. Во-первых, передача типа значения в качестве параметра функции создает независимую копию значения, так же как присвоение переменной. Во-вторых, параметр функции неизменяем в пределах функции (как переменная, объявленная с помощью let), поэтому мы не можем изменить его свойства. Чтобы создать аналогичную функцию, нам нужно будет использовать параметр inout, который мы рассмотрим в следующем разделе.
Мы надеемся, что этот первоначальный обзор поведения структур и классов подчеркивает их различные природы, несмотря на их сходства в синтаксисе и общие черты. В остальной части этой главы мы исследуем компромиссы между структурами и классами. Хотя классы являются более мощным инструментом, их возможности имеют свою цену. С другой стороны, структуры гораздо более ограничены, но эти ограничения также могут быть полезными.
Мутация Link to heading
Структуры и классы значительно различаются в отношении управления изменяемостью. Это может показаться неинтуитивным на первый взгляд, но станет понятным в свете различия между тем, что структуры являются типами значений, а классы — типами ссылок. В качестве примера мы снова используем типы ScoreClass и ScoreStruct из предыдущего раздела:
class ScoreClass {
var home: Int
var guest: Int
init (home: Int, guest: Int) {
self.home = home
self.guest = guest
}
}
struct ScoreStruct {
var home: Int
var guest: Int
// Инициализатор с параметрами, сгенерированный компилятором.
}
Обе версии имеют свойства home и guest, объявленные с использованием ключевого слова var. Если мы создадим экземпляры обоих и сохраним их в переменных var, мы сможем свободно изменять свойства:
var scoreClass = ScoreClass(home: 0, guest: 0)
var scoreStruct = ScoreStruct(home: 0, guest: 0)
scoreClass.home += 1
scoreStruct.guest += 1
Однако существует важное различие между изменением экземпляра класса и структуры: мутация структуры всегда локальна для переменной, которую мы изменяем, т.е. только значение локальной переменной scoreStruct изменяется. Изменение экземпляра класса может иметь потенциально глобальные последствия: любой, кто также имеет ссылку на тот же экземпляр, будет затронут изменением.
Если мы сохраним экземпляры в переменных let, мы все еще можем изменять экземпляр класса, но не экземпляр структуры:
let scoreClass = ScoreClass(home: 0, guest: 0)
let scoreStruct = ScoreStruct(home: 0, guest: 0)
scoreClass.home += 1 // работает
scoreStruct.guest += 1
// Ошибка: Левая сторона оператора мутации не изменяема:
// 'scoreStruct' — это константа 'let'.
Объявление переменной с помощью let означает, что ее значение не может быть изменено после инициализации. Поскольку значение переменной scoreClass является ссылкой на экземпляр ScoreClass, это означает, что мы не можем присвоить другую ссылку переменной scoreClass. Однако, чтобы изменить свойство экземпляра ScoreClass, который мы создали, нам не нужно изменять значение scoreClass. Мы просто используем ссылку в scoreClass, чтобы получить доступ к экземпляру, где мы можем изменять свойства, поскольку они были объявлены как var в классе.
В случае структур это работает совершенно иначе. Поскольку структуры являются типами значений, переменная scoreStruct не просто содержит ссылку на экземпляр где-то еще; она фактически содержит сам экземпляр ScoreStruct. Поскольку значение переменных let не может быть изменено после первоначального присвоения, мы не можем больше изменять свойство, даже если оно объявлено с помощью var в структуре. Причина в том, что изменение свойства внутри структуры семантически эквивалентно присвоению совершенно нового экземпляра структуры переменной. Таким образом, пример из выше:
scoreStruct.guest += 1
эквивалентен:
scoreStruct = ScoreStruct(home: scoreStruct.home, guest: scoreStruct.guest + 1)
Это относится не только к изменению прямого свойства экземпляра структуры, но и к изменению любого вложенного свойства. Например, присвоение нового значения координате x начала прямоугольника семантически эквивалентно присвоению совершенно нового значения прямоугольника переменной:
var rect = CGRect(origin: .zero, size: CGSize(width: 100, height: 100))
rect.origin.x = 10 // это то же самое, что и...
rect = CGRect(origin: CGPoint(x: 10, y: 0), size: rect.size)
Что произойдет, если мы объявим свойства с помощью let, но объявим переменные scoreClass и scoreStruct с помощью var?
class ScoreClass {
let home: Int
let guest: Int
init (home: Int, guest: Int) {
self.home = home
self.guest = guest
}
}
struct ScoreStruct {
let home: Int
let guest: Int
}
var scoreClass = ScoreClass(home: 0, guest: 0)
var scoreStruct = ScoreStruct(home: 0, guest: 0)
scoreClass.home += 1
// Ошибка: Левая сторона оператора мутации не изменяема:
// 'home' — это константа 'let'.
scoreStruct.guest += 1
// Ошибка: Левая сторона оператора мутации не изменяема:
// 'guest' — это константа 'let'.
Мутация не удается в случае класса, даже если scoreClass объявлен с помощью var. Причина в том, что var в объявлении переменной означает только то, что мы можем изменить значение переменной. В случае класса значение переменной — это ссылка на экземпляр, поэтому мы можем изменить ссылку:
scoreClass = ScoreClass(home: 2, guest: 1) // работает
Однако мы не можем изменить свойство home у экземпляра, на который ссылается scoreClass, потому что это свойство было определено с помощью let.
Мутация также не удается в случае структуры: поскольку свойства определены с помощью let, мы больше не можем использовать их для изменения значения в scoreStruct, даже если scoreStruct является var. Однако мы все еще можем присвоить новую структуру переменной scoreStruct:
scoreStruct = ScoreStruct(home: 2, guest: 1) // работает
Наконец, если мы определим свойства и переменные с помощью let, компилятор больше не позволяет никакого рода мутацию: мы не можем присваивать новые экземпляры переменным scoreClass или scoreStruct, и мы не можем изменять свойства экземпляра.
Мы рекомендуем использовать свойства var в структурах по умолчанию. Это позволяет контролировать изменяемость экземпляров структур, используя var или let на уровне переменной, что дает вам больше гибкости. В отличие от классов, использование свойств var в структурах не вводит потенциально глобальное изменяемое состояние, потому что изменение свойства структуры на самом деле просто создает копию структуры с измененным полем. let следует использовать экономно и целенаправленно для свойств, которые действительно не должны изменяться после инициализации (например, потому что изменение одного свойства приведет структуру в недопустимое состояние), даже если экземпляр хранится в переменной var.
Ключ к пониманию всех различных комбинаций свойств и переменных let и var заключается в том, чтобы помнить два момента: → Значение переменной класса — это ссылка на экземпляр, в то время как значение переменной структуры — это сам экземпляр структуры. → Изменение свойства структуры, даже через несколько уровней вложенности, эквивалентно присвоению совершенно нового экземпляра структуры переменной.
Методы изменения Link to heading
Обычные методы структур, определенные с помощью ключевого слова func, не могут изменять свойства структуры. Это связано с тем, что параметр self, который неявно передается в каждый метод, по умолчанию является неизменяемым. Мы должны явно указать mutating func, чтобы создать метод, который позволяет изменение:
extension ScoreStruct {
mutating func scoreGuest() {
self.guest += 1
}
}
var scoreStruct2 = ScoreStruct(home: 0, guest: 0)
scoreStruct2.scoreGuest()
scoreStruct2.guest // 1
Внутри метода изменения мы можем рассматривать self как переменную (var), поэтому мы можем изменять свойства self, если они также объявлены с помощью var.
Компилятор использует наличие ключевого слова mutating как маркер для определения, какие методы не могут быть вызваны на константах, объявленных с помощью let. Мы можем вызывать методы изменения только на переменных, объявленных с помощью var, поскольку вызов метода изменения подобен присвоению нового значения переменной (на самом деле, присвоение совершенно нового значения self также разрешено в методе изменения). Если мы попытаемся вызвать метод изменения на переменной let, компилятор выдаст ошибку, даже если этот метод фактически не изменяет self — аннотация mutating достаточно, чтобы запретить вызов.
Сеттеры свойств и сабскриптов по умолчанию являются изменяющими. В редких случаях, когда вы хотите реализовать вычисляемое свойство с неизменяемым сеттером (например, потому что ваша структура является оберткой для глобального ресурса и сеттер только изменяет глобальное состояние), вы можете аннотировать сеттер с помощью nonmutating set. Компилятор позволяет вам вызывать такой сеттер на константе let.
Классы не имеют и не нуждаются в методах изменения: как мы видели выше, мы можем изменять свойства экземпляра класса даже через переменную, объявленную с помощью let. Аналогично, self ведет себя как переменная let внутри методов класса. Мы не можем переназначить self, но можем использовать его для изменения свойств экземпляра, на который ссылается self, при условии, что эти свойства объявлены с помощью var.
inout Parameters Link to heading
Мы упоминали выше, что изменяющий метод в структуре имеет доступ к изменяемому self, и поэтому может изменять любое свойство var в self. Параметры inout позволяют нам писать функции, которые могут изменять любой из своих параметров на месте, а не только self. В качестве примера давайте напишем изменяющий метод scoreGuest как свободную функцию:
func scoreGuest( _ score: ScoreStruct) {
score.guest += 1
// Ошибка: Левая сторона изменяющего оператора не изменяема:
// 'score' является константой 'let'.
}
По умолчанию параметры функции, как и переменные let, являются неизменяемыми. Конечно, мы можем скопировать параметр в локальную переменную var, но изменение этой переменной не повлияет на оригинальное значение, которое было передано. Чтобы решить эту проблему, мы добавляем ключевое слово inout к типу параметра:
func scoreGuest( _ score: inout ScoreStruct) {
score.guest += 1
}
var scoreStruct3 = ScoreStruct(home: 0, guest: 0)
scoreGuest(&scoreStruct3)
scoreStruct3.guest // 1
Чтобы вызвать функцию scoreGuest с параметром inout, нам нужно сделать две вещи: во-первых, переменная, которую мы передаем как параметр inout, должна быть определена как var, и, во-вторых, мы должны префиксировать имя переменной символом &, когда передаем ее в функцию. Необходимый символ амперсанда делает очень ясным на стороне вызова, что функция теперь может изменить значение этой переменной.
Хотя амперсанд может напоминать вам оператор адреса в C или Objective-C, или передачу по ссылке в C++, это не то, что происходит в данном случае. Параметры inout передаются как копии, как и обычный параметр, но они копируются обратно, когда функция возвращается. Другими словами, когда функция изменяет параметр inout несколько раз, вызывающий код увидит только одно изменение, так как новое значение копируется обратно. По той же логике, даже если функция вообще не изменяет свой параметр inout, вызывающий код все равно увидит мутацию (т.е. любые наблюдатели willSet и didSet будут срабатывать).
Как мы объяснили в главе о функциях, компилятор может оптимизировать копирование на вход и выход, чтобы передавать по ссылке, если он может сделать это безопасно.
Жизненный цикл Link to heading
Структуры и классы очень различаются с точки зрения управления жизненным циклом. Структуры гораздо проще в этом отношении, поскольку они не могут иметь нескольких владельцев; их время жизни связано с временем жизни переменной, содержащей структуру. Когда переменная выходит из области видимости, ее память освобождается, и структура исчезает.
В отличие от этого, экземпляр класса может ссылаться на нескольких владельцев, что требует более сложной модели управления памятью. Swift использует автоматическое подсчитывание ссылок (ARC) для отслеживания количества ссылок на конкретный экземпляр. Когда счетчик ссылок уменьшается до нуля (потому что все переменные, держащие ссылку, вышли из области видимости или были установлены в nil), среда выполнения Swift вызывает deinit объекта и освобождает память. Поэтому классы могут использоваться для моделирования общих сущностей, которые выполняют работу по очистке, когда они в конечном итоге освобождаются. Примеры этого включают файловые дескрипторы (которые должны закрывать свои подлежащие файловые дескрипторы) и контроллеры представлений, которые могут потребоваться для отмены регистрации наблюдателя уведомлений.
ЦиклыСсылок Link to heading
Цикл ссылок — это ситуация, когда два или более объекта ссылаются друг на друга сильно таким образом, что это предотвращает их освобождение (если только разработчик явно не разорвет цикл). Это создает утечки памяти и мешает выполнению потенциальных задач по очистке.
Поскольку структуры являются простыми значениями, невозможно создать циклы ссылок между ними (так как нет ссылок на структуры). Это является преимуществом с одной стороны и ограничением с другой: это одна забота меньше, но это также означает, что мы не можем моделировать циклические структуры данных с помощью структур. Для классов ситуация обратная: поскольку один и тот же экземпляр может иметь несколько владельцев, мы можем использовать классы для моделирования циклических структур данных, но нам нужно быть осторожными, чтобы не создавать циклы ссылок.
Циклы ссылок могут принимать множество форм — от двух объектов, ссылающихся друг на друга сильно, до сложных циклов, состоящих из множества объектов и замыкающих объектов. Давайте сначала рассмотрим простой пример, связанный с окном и его корневым представлением:
// Первая версия
class Window {
var rootView: View?
}
class View {
var window: Window
init(window: Window) {
self.window = window
}
}
var window: Window? = Window() // счетчик ссылок: 1
window = nil // счетчик ссылок: 0, освобождение
После первой строки счетчик ссылок равен одному. В момент, когда мы устанавливаем переменную в nil, счетчик ссылок нашего экземпляра Window становится нулевым, и экземпляр освобождается.
Однако, если мы также создадим представление и присвоим его свойству rootView окна, счетчик ссылок больше никогда не опустится до нуля. Давайте проследим за счетчиками ссылок построчно.
Сначала создается окно. Счетчик ссылок для окна теперь равен одному:
var window: Window? = Window() // окно: 1
Затем создается представление, которое удерживает сильную ссылку на окно, поэтому счетчик ссылок окна теперь равен двум, а счетчик ссылок представления равен одному:
var view: View? = View(window: window!) // окно: 2, представление: 1
Присвоение представления свойству rootView окна увеличивает счетчик ссылок представления на один. Теперь и представление, и окно имеют счетчик ссылок равный двум:
window?.rootView = view // окно: 2, представление: 2
После установки обеих переменных в nil, они все еще имеют счетчик ссылок равный одному:
view = nil // окно: 2, представление: 1
window = nil // окно: 1, представление: 1
Несмотря на то, что они больше не доступны из переменной, они сильно ссылаются друг на друга. Это называется циклом ссылок, и при работе с графоподобными структурами данных нам нужно быть очень внимательными к потенциальной возможности создания утечек памяти через циклы.
Из-за цикла ссылок эти два объекта никогда не будут освобождены в течение времени жизни программы.
Слабые ссылки (Weak References) Link to heading
Чтобы разорвать цикл ссылок, нам нужно сделать одну из ссылок слабой или неявной (unowned). Присвоение объекта слабой переменной не изменяет его счетчик ссылок. Слабые ссылки в Swift всегда обнуляются: переменная автоматически будет установлена в nil, как только связанный объект будет освобожден. Именно поэтому слабые ссылки всегда должны быть опциональными.
Чтобы исправить приведенный выше пример, мы сделаем свойство rootView окна слабым, что означает, что оно не будет сильно ссылаться на представление и, следовательно, автоматически станет nil, как только представление будет освобождено. Чтобы увидеть, что происходит, мы можем добавить несколько операторов print в деструкторы классов. Деструктор (deinit) вызывается непосредственно перед освобождением класса:
// Вторая версия
class Window {
weak var rootView: View?
deinit {
print("Deinit Window")
}
}
class View {
var window: Window
init(window: Window) {
self.window = window
}
deinit {
print("Deinit View")
}
}
В приведенном ниже коде мы снова создаем окно и представление. Как и прежде, представление сильно ссылается на окно; но поскольку свойство rootView окна объявлено как слабое, окно больше не ссылается на представление сильно. Таким образом, у нас нет цикла ссылок, и оба объекта освобождаются, когда мы устанавливаем переменные в nil:
var window: Window? = Window()
var view: View? = View(window: window!)
window?.rootView = view
window = nil
view = nil
/*
Deinit View
Deinit Window
*/
Слабые ссылки очень полезны при работе с делегатами, как это обычно бывает в Cocoa. Делегирующий объект (например, UITableView) нуждается в ссылке на своего делегата, но он не должен владеть делегатом, так как это, вероятно, создаст цикл ссылок. Поэтому ссылки на делегаты обычно являются слабыми, а другой объект (например, UIViewController) отвечает за то, чтобы делегат оставался доступным столько, сколько это необходимо.
UnownedReferences Link to heading
Иногда, однако, нам нужна не сильная ссылка, которая не является опциональной. Например, возможно, мы знаем, что наши представления всегда будут иметь окно (поэтому свойство не должно быть опциональным), но мы не хотим, чтобы представление сильно ссылалось на окно. Для таких ситуаций существует ключевое слово unowned:
// Третья версия
class Window {
var rootView: View?
deinit {
print("Deinit Window")
}
}
class View {
unowned var window: Window
init(window: Window) {
self.window = window
}
deinit {
print("Deinit View")
}
}
В приведенном ниже коде мы можем видеть, что оба объекта освобождаются, как и в предыдущем примере с слабой ссылкой:
var window: Window? = Window()
var view: View? = View(window: window!)
window?.rootView = view
view = nil
window = nil
/*
Deinit Window
Deinit View
*/
С несуществующими ссылками мы несем ответственность за то, чтобы “ссылка” пережила “ссылку”. В этом примере мы должны убедиться, что окно переживет представление. Если окно будет освобождено до того, как представление будет освобождено, и если к несуществующей переменной будет осуществлен доступ, программа завершится с ошибкой.
Обратите внимание, что это не то же самое, что и неопределенное поведение. Время выполнения Swift поддерживает вторичный счетчик ссылок в объекте, чтобы отслеживать несуществующие ссылки. Когда все сильные ссылки исчезнут, объект освободит все свои ресурсы (например, любые ссылки на другие объекты). Однако память самого объекта все еще будет доступна, пока все несуществующие ссылки также не исчезнут. Память помечается как недействительная (иногда также называется “зомби-памятью”), и всякий раз, когда мы пытаемся получить доступ к несуществующей ссылке, возникает ошибка времени выполнения.
Эта защита может быть обойдена с помощью unowned(unsafe). Если мы получаем доступ к недействительной ссылке, которая помечена как unowned(unsafe), мы получаем неопределенное поведение.
Замыкания и циклы ссылок Link to heading
Классы не являются единственным типом ссылочного типа в Swift. Существуют также акторы, которые мы рассмотрим в главе о параллелизме, и функции (которые также включают выражения замыканий и методы). Если замыкание захватывает переменную, содержащую ссылочный тип, замыкание будет поддерживать на него сильную ссылку. В дополнение к предыдущему примеру, это другой основной способ введения циклов ссылок в ваш код.
Обычная схема выглядит так: объект A ссылается на объект B, но объект B хранит замыкание, которое ссылается на объект A (на практике цикл ссылок может включать несколько промежуточных объектов и замыканий). В качестве примера мы добавим необязательный коллбек onRotate в класс окна из вышеуказанного примера:
class Window {
weak var rootView: View?
var onRotate: (() -> ())? = nil
}
Если мы настроим коллбек onRotate и используем представление в нем, мы ввели цикл ссылок:
var window: Window? = Window()
var view: View? = View(window: window!)
window?.onRotate = {
print("Теперь нам также нужно обновить представление: \(String(describing: view))")
}
Существует три места, где мы могли бы разорвать этот цикл ссылок (каждое соответствует стрелке на диаграмме выше):
→ Мы могли бы сделать ссылку представления на окно слабой. К сожалению, окно будет немедленно освобождено, так как нет других ссылок, которые поддерживают его в живых.
→ Мы могли бы захотеть пометить свойство onRotate как слабое, но Swift не позволяет помечать свойства функций как слабые.
→ Мы могли бы убедиться, что замыкание не слабо ссылается на представление, используя список захвата, который захватывает представление слабо. Это единственный правильный вариант в этом примере.
window?.onRotate = { [ weak view] in
print("Теперь нам также нужно обновить представление: \(String(describing: view))")
}
Списки захвата могут делать больше, чем просто помечать переменные как слабые или не имеющие владельца. Например, если мы хотели бы иметь слабую переменную, которая ссылается на окно, мы могли бы инициализировать ее в списке захвата, или мы могли бы даже определить совершенно не связанные переменные, например так:
window?.onRotate = { [ weak view, weak myWindow=window, x=5*5] in
print("Теперь нам также нужно обновить представление: \(String(describing: view))")
print("окно: \(String(describing: myWindow)), x: \(x)")
}
Это почти то же самое, что и определение переменной прямо перед замыканием, за исключением того, что в списках захвата область видимости переменной — это только область видимости замыкания; она недоступна за пределами замыкания.
Выбор между неуправляемыми и слабыми ссылками Link to heading
Следует ли предпочитать неуправляемые или слабые ссылки в ваших собственных API? В конечном итоге ответ на этот вопрос сводится к срокам жизни вовлеченных объектов. Если объекты имеют независимые сроки жизни — то есть, если вы не можете сделать никаких предположений о том, какой объект переживет другой — слабая ссылка является единственно безопасным выбором.
С другой стороны, если вы можете гарантировать, что объект, на который ссылаются не сильно, имеет такой же срок жизни, как и его аналог, или всегда будет жить дольше, неуправляемая ссылка часто оказывается более удобной. Это связано с тем, что она не должна быть опциональной, и переменная может быть объявлена с помощью let, в то время как слабые ссылки всегда должны быть опциональными var. Ситуации с одинаковыми сроками жизни очень распространены, особенно когда два объекта имеют родительско-дочерние отношения. Когда родитель контролирует срок жизни дочернего объекта с помощью сильной ссылки и вы можете гарантировать, что другие объекты не знают о дочернем объекте, обратная ссылка дочернего объекта на родителя всегда может быть неуправляемой.
Неуправляемые ссылки также имеют меньшие накладные расходы, чем слабые ссылки, поэтому доступ к свойству или вызов метода на неуправляемой ссылке будет немного быстрее. Тем не менее, это следует учитывать только в очень критичных по производительности участках кода.
Недостатком предпочтения неуправляемых ссылок является, конечно, то, что ваша программа может аварийно завершиться, если вы допустите ошибку в своих предположениях о сроках жизни. Лично мы часто предпочитаем слабые ссылки, даже когда можно использовать неуправляемые, потому что первые заставляют нас явно проверять, действительна ли ссылка в каждой точке использования. Особенно при рефакторинге кода легко нарушить предыдущие предположения о сроках жизни и ввести аварийные ошибки.
Но также есть аргумент в пользу того, чтобы всегда использовать модификатор, который отражает характеристики срока жизни, которые вы ожидаете от вашего кода, чтобы сделать их явными. Если вы или кто-то другой позже изменит код таким образом, что это сделает эти предположения недействительными, жесткое завершение работы программы, возможно, будет разумным способом уведомить вас о проблеме — при условии, что вы найдете ошибку во время тестирования.
Выбор между структурами и классами Link to heading
При проектировании типа нам необходимо подумать о том, нужно ли делить владение конкретным экземпляром этого типа между различными частями нашей программы, или же несколько экземпляров могут использоваться взаимозаменяемо, если они представляют одно и то же значение. Для того чтобы делить владение конкретным экземпляром, нам нужно использовать класс. В противном случае мы можем использовать структуру.
Например, экземпляр URL не может быть разделен, потому что URL — это структура. Каждый раз, когда мы присваиваем URL переменной или передаем его функции, компилятор создаст копию. Однако это не проблема, потому что мы считаем два экземпляра URL взаимозаменяемыми, если они представляют один и тот же URL. То же самое относится и к другим структурам, таким как целые числа, логические значения и строки: нам не важно, связаны ли два целых числа или две строки с одним и тем же участком памяти; нам важно, представляют ли они одно и то же значение.
В отличие от этого, мы не рассматриваем два экземпляра UIView как взаимозаменяемые. Даже если все их свойства одинаковы, они все равно представляют разные «объекты» на экране в разных местах иерархии представлений. Поэтому UIView моделируется как класс, чтобы мы могли передавать ссылку на конкретный экземпляр между несколькими частями нашей программы: конкретное представление ссылается на его супервью, но также и на его дочерние представления как на свое супервью. Кроме того, мы можем хранить дополнительные ссылки на представление, например, в контроллере представления. Один и тот же экземпляр представления может быть изменен через все ссылки, и эти изменения автоматически отражаются во всех ссылках.
Сказав это, когда мы проектируем тип, который не требует совместного владения, нам не обязательно использовать структуру. Мы также можем смоделировать его как класс, потенциально предоставляя неизменяемый API, так что тип фактически имеет семантику значений. В этом смысле мы можем обойтись только классами, не изменяя радикально способ проектирования нашей программы. Конечно, мы потеряем некоторые проверки на этапе компиляции, касающиеся изменяемости, и можем понести затраты на дополнительные операции подсчета ссылок, но мы могли бы заставить это работать.
С другой стороны, если бы у нас не было классов (или ссылок в общем) в нашем распоряжении, мы бы потеряли всю концепцию совместного владения и нам пришлось бы заново проектировать нашу программу с нуля (при условии, что мы ранее полагались на классы). Поэтому, хотя мы можем смоделировать структуру как класс с некоторыми компромиссами, обратное не обязательно верно. Структуры — это инструмент в нашем арсенале, который целенаправленно менее способен, чем классы. Взамен структуры предлагают простоту: никаких ссылок, никакого жизненного цикла, никаких подтипов. Это означает, что нам не нужно беспокоиться о циклах ссылок, побочных эффектах и гонках данных через общие ссылки, а также о правилах наследования — чтобы назвать лишь несколько примеров.
Кроме того, структуры обещают лучшую производительность, особенно для небольших значений. Например, если бы Int был классом, массив Int занял бы гораздо больше памяти для хранения ссылок (указателей) на фактические экземпляры, вместе с дополнительными накладными расходами, которые требует каждый экземпляр (например, для хранения его счетчика ссылок). Еще более важно, что перебор этого массива был бы значительно медленнее, потому что коду пришлось бы следовать дополнительному уровню косвенной адресации для каждого элемента и, таким образом, потенциально не смог бы эффективно использовать кэши ЦП, особенно если экземпляры Int были бы выделены в совершенно разных местах памяти.
Классы с семантикой значений Link to heading
Выше мы отметили, что структуры имеют семантику значений (т.е. каждая переменная содержит независимое значение), а классы имеют семантику ссылок (т.е. несколько переменных могут указывать на один и тот же экземпляр класса). Хотя это правда, мы можем написать неизменяемые классы, которые ведут себя больше как тип значения, и мы можем написать структуры, которые на первый взгляд не ведут себя как тип значения.
При написании класса мы можем зафиксировать его до такой степени, что его семантика ссылок больше не будет влиять на его поведение. Сначала мы объявляем все свойства как let, делая их неизменяемыми. Затем мы делаем класс final, чтобы запретить наследование, с целью предотвратить возможные подклассы от повторного введения какого-либо изменяемого поведения:
final class ScoreClass {
let home: Int
let guest: Int
init(home: Int, guest: Int) {
self.home = home
self.guest = guest
}
}
let score1 = ScoreClass(home: 0, guest: 0)
let score2 = score1
Переменные score1 и score2 все еще содержат ссылки на один и тот же экземпляр ScoreClass — так работают классы, в конце концов. Однако для всех практических целей мы можем использовать score1 и score2, как если бы они содержали независимые значения, поскольку подлежащий экземпляр в любом случае полностью неизменяем.
Примером этого является класс NSArray в Foundation. Сам NSArray не предоставляет никаких изменяющих API, поэтому его экземпляры могут по сути использоваться так, как если бы они были значениями. Реальность несколько сложнее, поскольку у NSArray есть изменяемый подкласс NSMutableArray, и мы не можем делать предположения о том, что мы действительно имеем дело с экземпляром NSArray, если мы не создали его сами. Вот почему мы объявили наш класс как final выше, и именно поэтому рекомендуется сделать копию NSArray, которую вы получаете из API, который вы не контролируете, прежде чем делать что-либо еще с ней.
Структуры с семантикой ссылок Link to heading
Обратная ситуация — структуры, содержащие свойства ссылочного типа — также демонстрируют удивительное поведение. Давайте расширим тип ScoreStruct, добавив вычисляемое свойство pretty, которое предоставляет красиво отформатированную строку для текущего счета:
struct ScoreStruct {
var home: Int
var guest: Int
let scoreFormatter: NumberFormatter
init(home: Int, guest: Int) {
self.home = home
self.guest = guest
scoreFormatter = NumberFormatter()
scoreFormatter.minimumIntegerDigits = 2
}
var pretty: String {
let h = scoreFormatter.string(from: home as NSNumber)!
let g = scoreFormatter.string(from: guest as NSNumber)!
return "\(h) – \(g)"
}
}
let score1 = ScoreStruct(home: 2, guest: 1)
score1.pretty // 02 – 01
В инициализаторе мы создаем форматировщик чисел, который настроен на отображение как минимум двух целых цифр, даже если счет меньше 10. Мы используем этот форматировщик в свойстве pretty для получения отформатированного вывода.
Теперь давайте создадим копию score1 и затем перенастроим форматировщик чисел в этой копии:
let score2 = score1
score2.scoreFormatter.minimumIntegerDigits = 3
Хотя мы внесли изменения в score2, вывод score1.pretty также изменился:
score1.pretty // 002 – 001
Причина этого в том, что NumberFormatter является классом, т.е. свойство scoreFormatter в нашей структуре содержит ссылку на экземпляр форматировщика чисел. Когда мы присвоили score1 новой переменной score2, была создана копия score1. Однако копия структуры — это копия всех значений ее свойств, а значение scoreFormatter — это всего лишь ссылка. Поэтому значение ScoreStruct в score2 содержит ссылку на тот же экземпляр форматировщика чисел, что и score1.
Технически, ScoreStruct все еще имеет семантику значений: когда вы присваиваете экземпляр другой переменной или передаете его в качестве параметра функции, программа создает копию всего значения. Однако это зависит от того, что мы считаем значением. Если мы намеренно хотим хранить ссылку в одном из свойств структуры, т.е. рассматриваем саму ссылку как значение, тогда приведенная выше структура демонстрирует именно то поведение, которое мы хотели. Но, вероятно, мы хотели, чтобы структура включала сам экземпляр форматировщика чисел, чтобы копии имели свои собственные форматировщики. В этом случае поведение приведенной выше структуры некорректно.
Чтобы предотвратить неожиданное поведение в приведенном выше примере, мы могли бы либо изменить тип на класс (чтобы пользователь этого типа не ожидал семантики значений), либо сделать форматировщик чисел приватной реализацией, чтобы его нельзя было изменить. Однако последнее не является идеальным решением: мы все равно можем (случайно) открыть другие публичные методы на типе, которые будут изменять форматировщик чисел внутри.
Мы рекомендуем быть очень осторожными при хранении ссылок внутри структур, так как это часто приводит к неожиданному поведению. Тем не менее, есть случаи, когда хранение ссылки является намеренным и именно тем, что вам нужно, в основном как деталь реализации для оптимизации производительности. Мы рассмотрим пример этого в следующем разделе, который охватывает копирование при записи.
Оптимизация Copy-On-Write Link to heading
Типы значений требуют много копирования, так как присвоение значения или передача его в качестве параметра функции создает копию. Хотя компилятор старается быть умным и избегать копий, когда может доказать, что это безопасно, существует еще одна оптимизация, которую автор типа значения может сделать, и это реализация типа с использованием техники, называемой copy-on-write. Это особенно важно для типов, которые могут содержать большие объемы данных, таких как коллекции стандартной библиотеки (Array, Dictionary, Set и String). Все они реализованы с использованием copy-on-write.
Copy-on-write означает, что данные в структуре изначально разделяются между несколькими переменными; копирование данных откладывается до тех пор, пока экземпляр не изменит свои данные. Поскольку массивы реализованы с использованием copy-on-write, если мы создаем массив и присваиваем его другой переменной, данные массива на самом деле еще не были скопированы:
var x = [1, 2, 3]
var y = x
Внутри значения массива в x и y содержат ссылку на один и тот же буфер памяти. Этот буфер — это место, где хранятся фактические элементы массива. Однако в момент, когда мы изменяем x (или y, если на то пошло), массив обнаруживает, что он делит свой буфер с одной или несколькими другими переменными, и делает копию буфера перед применением мутации. Это означает, что мы можем изменять обе переменные независимо, но потенциально дорогое копирование элементов происходит только тогда, когда это необходимо:
x.append(5)
y.removeLast()
x // [1, 2, 3, 5]
y // [1, 2]
Поведение copy-on-write не является чем-то, что мы получаем бесплатно для наших собственных типов; мы должны реализовать это сами, так же как стандартная библиотека реализует это для своих типов коллекций. Однако реализация copy-on-write для пользовательской структуры необходима только в редких случаях, так как стандартная библиотека уже предоставляет наиболее распространенные типы, которые работают с большими объемами данных. Даже если мы определим структуру, которая может содержать много данных, мы часто будем использовать встроенные типы коллекций для представления этих данных внутри, и, как следствие, мы получаем выгоду от их оптимизаций copy-on-write.
Тем не менее, понимание того, как может быть реализован copy-on-write, полезно для понимания поведения коллекций Swift в целом, а также некоторых крайних случаев, о которых нам следует знать.
Копирование при записи: компромиссы Link to heading
Прежде чем мы рассмотрим реализацию копирования при записи, мы хотим отметить, что у этого подхода есть свои компромиссы. Одним из преимуществ типов значений является то, что они не несут накладных расходов на подсчет ссылок. Однако структуры, использующие копирование при записи, полагаются на хранение ссылки внутри, и внутренний счетчик ссылок должен увеличиваться для каждой копии структуры, которая создается. Таким образом, мы действительно отказываемся от преимущества типов значений — отсутствия необходимости в подсчете ссылок — чтобы смягчить потенциальные затраты, связанные с другой характеристикой типов значений — семантикой копирования.
Увеличение или уменьшение счетчика ссылок является относительно медленной операцией (по сравнению, скажем, с копированием нескольких байтов в другое место в стеке), поскольку такая операция должна быть безопасной для потоков и, следовательно, несет накладные расходы на блокировку. Поскольку все типы переменного размера из стандартной библиотеки — массивы, словари, множества и строки — полагаются на копирование при записи, все структуры, содержащие свойства этих типов, также несут затраты на подсчет ссылок при каждой копии — потенциально даже несколько раз, когда тип содержит несколько из этих свойств (одним из исключений является случай небольших строк размером до 15 кодовых единиц UTF-8, для которых Swift реализует оптимизацию, которая полностью избегает выделения буфера).
Практический пример этого можно найти в проекте SwiftNIO: HTTP-запрос ранее моделировался как структура в SwiftNIO, и он содержал несколько свойств, таких как метод HTTP, заголовки и т. д. Когда такая структура копировалась, не только все ее поля должны были быть скопированы, но и счетчики ссылок для всех внутренних массивов, словарей и строк также должны были увеличиваться. Эта накладная работа приводила к значительно худшей производительности при передаче значения этого типа (что было очень распространенной операцией) по сравнению с передачей HTTP-запроса, смоделированного как класс (поскольку ссылка на класс меньше, чем все поля структуры HTTP-запроса, и только один счетчик ссылок должен быть обновлен).
Ниже мы рассмотрим, как мы можем использовать технику копирования при записи, чтобы объединить лучшее из обоих миров в этом конкретном случае: семантику значений и преимущества производительности использования класса. Иоганнес Вейс из команды SwiftNIO также провел отличное выступление на эту тему на dotSwift 2019.
Реализация Copy-On-Write Link to heading
Мы начинаем с крайне упрощенной версии структуры HTTP-запроса:
struct HTTPRequest {
var path: String
var headers: [String: String]
// другие поля опущены...
}
Чтобы минимизировать накладные расходы на подсчет ссылок, описанные выше, мы сначала обернем все свойства в приватный класс хранения:
struct HTTPRequest {
fileprivate class Storage {
var path: String
var headers: [String: String]
init(path: String, headers: [String: String]) {
self.path = path
self.headers = headers
}
}
private var storage: Storage
init(path: String, headers: [String: String]) {
storage = Storage(path: path, headers: headers)
}
}
Таким образом, наша структура HTTPRequest содержит только одно свойство — storage, и ей требуется только один счетчик ссылок для внутреннего экземпляра хранения, который нужно увеличить при копировании.
Чтобы сделать теперь приватные свойства path и headers внутреннего экземпляра хранения доступными, мы добавляем вычисляемые свойства в структуру:
extension HTTPRequest {
var path: String {
get { storage.path }
set { /* to do */ }
}
var headers: [String: String] {
get { storage.headers }
set { /* to do */ }
}
}
Важная часть — это реализация сеттеров для этих свойств: мы не должны просто устанавливать новое значение на внутреннем экземпляре хранения, потому что этот объект потенциально может быть разделен между несколькими переменными. Поскольку хранение данных запроса в экземпляре класса должно быть частной деталью реализации, мы должны убедиться, что наша структура на основе класса ведет себя точно так же, как и оригинальная. Это означает, что изменение свойства переменной HTTP-запроса должно изменять только значение этой переменной.
В качестве первого шага мы можем создать копию внутреннего класса хранения каждый раз, когда вызывается сеттер. Чтобы сделать копию, мы добавляем метод copy в Storage:
// Печатаем в Swift.print, чтобы захватить вывод для тестирования.
var printedLines: [String] = []
func print(_ value: Any) {
var output = ""
Swift.print(value, terminator: "", to: &output)
printedLines.append(output)
Swift.print(output)
}
extension HTTPRequest.Storage {
func copy() -> HTTPRequest.Storage {
print("Создание копии...") // Для отладки
return HTTPRequest.Storage(path: path, headers: headers)
}
}
Затем мы можем присвоить копию текущего хранения свойству storage перед тем, как установить новое значение:
extension HTTPRequest {
var path: String {
get {
storage.path
}
set {
storage = storage.copy()
storage.path = newValue
}
}
var headers: [String: String] {
get {
storage.headers
}
set {
storage = storage.copy()
storage.headers = newValue
}
}
}
Структура HTTPRequest теперь полностью поддерживается экземпляром класса, но она по-прежнему демонстрирует семантику значений, как если бы все ее свойства были свойствами самой структуры:
let req1 = HTTPRequest(path: "/home", headers: [:])
var req2 = req1
req2.path = "/users"
assert(req1.path == "/home") // проходит
Однако текущая реализация все еще неэффективна. Мы создаем копию внутреннего хранения каждый раз, когда вносим изменения, независимо от того, ссылаются ли другие переменные на то же самое хранилище или нет:
var req = HTTPRequest(path: "/home", headers: [:])
for x in 0..<5 {
req.headers["X-RequestId"] = "\(x)"
}
/*
Создание копии...
Создание копии...
Создание копии...
Создание копии...
Создание копии...
*/
Каждый раз, когда мы изменяем запрос, создается новая копия. Все эти копии ненужны; существует только одно значение HTTPRequest в req, которое ссылается на внутренний экземпляр хранения.
Чтобы обеспечить эффективное поведение copy-on-write, нам нужно знать, уникально ли ссылается объект (в нашем случае экземпляр Storage), т.е. есть ли у него единственный владелец. Если да, мы можем изменить объект на месте. В противном случае мы создаем копию объекта перед его изменением.
Мы можем использовать функцию isKnownUniquelyReferenced, чтобы узнать, имеет ли ссылка только одного владельца. Если вы передаете экземпляр класса Swift в эту функцию, и если никто другой не имеет сильной ссылки на объект, функция возвращает true. Если есть другие сильные ссылки, она возвращает false.
Есть несколько тонких моментов, которые следует учитывать при использовании isKnownUniquelyReferenced:
- Функция потокобезопасна, но вы должны убедиться, что переменная, передаваемая в нее, не доступна из другого потока. (
isKnownUniquelyReferencedне является специальной в этом отношении; это ограничение применяется ко всем аргументамinoutв Swift.) Другими словами,isKnownUniquelyReferencedне защищает от условий гонки — этот код не безопасен, потому что обе очереди изменяют одну и ту же переменную одновременно.
var numbers = [1, 2, 3]
queue1.async { numbers.append(4) }
queue2.async { numbers.append(5) }
isKnownUniquelyReferencedиспользует аргументinout, потому что это единственный способ в Swift ссылаться на переменную в контексте аргумента функции. Если бы аргумент передавался нормально, компилятор всегда создавал бы копию при вызове функции, что означало бы, что объект, который тестируется, никогда не мог бы быть уникально ссылочным внутри тела функции.- Ссылки
unownedиweakне учитываются, т.е. мы должны убедиться, что таких ссылок на рассматриваемый экземпляр не существует. isKnownUniquelyReferencedне работает для классов Objective-C. Чтобы обойти это ограничение, мы можем обернуть экземпляр класса Objective-C в класс Swift.
Используя эти знания, мы можем теперь написать вариант HTTPRequest, который проверяет, уникально ли ссылается storage перед его изменением. Чтобы избежать написания этих проверок в каждом сеттере свойства, мы обернем логику в свойство storageForWriting:
extension HTTPRequest {
private var storageForWriting: HTTPRequest.Storage {
mutating get {
if !isKnownUniquelyReferenced(&storage) {
self.storage = storage.copy()
}
return storage
}
}
var path: String {
get { storage.path }
set { storageForWriting.path = newValue }
}
var headers: [String: String] {
get { storage.headers }
set { storageForWriting.headers = newValue }
}
}
Чтобы протестировать наш код, давайте снова напишем цикл:
var req = HTTPRequest(path: "/home", headers: [:])
var copy = req
for x in 0..<5 {
req.headers["X-RequestId"] = "\(x)"
} // Создание копии...
Отладочное сообщение выводится только один раз: когда мы изменяем req в первый раз. В последующих итерациях уникальность обнаруживается, и копия не создается. В сочетании с оптимизациями, выполненными компилятором, copy-on-write избегает большинства ненужных копий типов значений.
willSet нарушает оптимизацию Copy-On-Write Link to heading
Вот ловушка производительности, о которой стоит знать: наличие наблюдателя willSet на свойстве для типа с оптимизацией copy-on-write нарушает эту оптимизацию. Это происходит потому, что willSet делает переменную newValue доступной внутри своего тела, заставляя компилятор создать временную копию значения. В результате значение больше не будет уникально ссылаться — любое изменение свойства вызовет копирование.
Чтобы увидеть это, мы определим три свойства HTTPRequest и прикрепим к ним разные наблюдатели изменений:
struct Wrapper {
var reqWithNoObservers = HTTPRequest(path: "/", headers: [:])
var reqWithWillSet = HTTPRequest(path: "/", headers: [:]) {
**willSet** {
print("willSet")
}
}
var reqWithDidSet = HTTPRequest(path: "/", headers: [:]) {
**didSet** {
print("didSet")
}
}
}
Обратите внимание, что свойства являются независимыми значениями, каждое из которых имеет свое уникально ссылающееся хранилище. Изменение этих значений не должно вызывать copy-on-write, и именно это мы наблюдаем для свойств без декоратора и с didSet:
var wrapper = Wrapper()
wrapper.reqWithNoObservers.path = "/about"
wrapper.reqWithDidSet.path = "/forum"
// didSet
Однако свойство с аннотацией willSet действительно вызывает copy-on-write при изменении:
wrapper.reqWithWillSet.path = "/blog"
/*
Making a copy...
willSet
*/
Чтобы понять это поведение, нам нужно рассмотреть порядок операций. Перед вызовом willSet создается копия существующего значения. Эта копия затем изменяется, вызывается willSet с доступным newValue в теле, и, наконец, изменяется хранилище свойства. Для didSet поведение аналогично: копия создается перед изменением, и эта копия доступна через oldValue. Однако в приведенном выше примере компилятор был достаточно умным, чтобы обнаружить, что мы не использовали oldValue в нашем didSet, и оптимизировал копию. Для willSet он этого не делает.
Представьте себе версию Swift с этой оптимизацией, включенной для willSet. Просто использование newValue или его отсутствие изменило бы порядок операций: если вы не используете newValue, компилятору нужно было бы вызвать willSet перед изменением, и любые побочные эффекты внутри willSet произошли бы до побочных эффектов изменения. В то время как сейчас побочные эффекты изменения всегда происходят перед willSet. Изменение этого поведения сейчас могло бы сломать все программы, которые полагаются на этот порядок операций. Для didSet команда Swift смогла изменить семантику.
Семантика willSet может стать проблемой производительности в коде SwiftUI, потому что обертка свойства @Published, используемая ObservableObject, использует willSet под капотом. Например, рассмотрим массив, который служит источником правды для длинного списка:
class ViewModel: ObservableObject {
@Published var cities: [String]
}
Каждое изменение свойства cities создаст копию хранилища базового массива.
Резюме Link to heading
В этой главе мы увидели, как структуры (значимые типы) и классы (ссылочные типы) имеют принципиально различное поведение, несмотря на общие черты. Переменная значимого типа просто содержит значение, и каждое присваивание другой переменной или передача его в функцию создает копию значения. В то время как переменная ссылочного типа содержит ссылку на фактическое значение. Присваивание его другой переменной или передача в функцию создает копию ссылки, а не самого основного значения.
Мы обсудили, как управлять изменяемостью с помощью let и var, как работает ключевое слово mutating, и как используются параметры inout. Наконец, мы показали, как работает оптимизация “копирование при записи” (которая используется многими типами в стандартной библиотеке) и как вы можете реализовать ее для своих собственных структур.
Перечисления (Enums) Link to heading
7 Link to heading
Структуры и классы, которые мы обсуждали в предыдущей главе, являются примерами типов записей. Запись состоит из нуля или более полей (свойств), при этом каждое поле имеет свой собственный тип. Кортежи также попадают в эту категорию: кортеж фактически является легковесной анонимной структурой с меньшими возможностями. Записи — это настолько очевидная концепция, что мы принимаем их как должное. Почти все языки программирования позволяют вам определять составные типы такого рода (ранние версии BASIC и оригинальный Lisp, возможно, являются наиболее известными исключениями). Даже программисты на ассемблере всегда использовали концепцию записей для структурирования данных в памяти, хотя и без поддержки языка.
Перечисления Swift, или enums, принадлежат к принципиально другой категории, которую иногда называют тегированными объединениями, вариантными типами или суммарными типами. Несмотря на то что суммарные типы являются концепцией, столь же мощной, как и записи, поддержка их гораздо менее распространена в основных языках программирования. Суммарные типы распространены в функциональных языках, однако они стали популярными в более новых языках, таких как Rust. На наш взгляд, перечисления являются одной из лучших особенностей Swift.
Обзор Link to heading
Перечисление (enum) состоит из нуля или более случаев (cases), при этом каждый случай может иметь необязательный список связанных значений в стиле кортежа. В этой главе мы иногда будем использовать единственное число “связанное значение” (associated value), когда говорим о связанных значениях одного случая. У случая может быть несколько связанных значений, но вы можете рассматривать эти значения как единый кортеж.
Вот перечисление для представления выравнивания абзаца. У случаев нет связанных значений:
enum TextAlignment {
case left
case center
case right
}
Мы видели в главе об Опционалах, что Optional — это обобщенное перечисление с двумя случаями — none и some. Случай some имеет связанное значение для упакованного значения:
**@frozen enum** Optional<Wrapped> {
/// Отсутствие значения.
case none
/// Наличие значения, хранящегося как `Wrapped`.
case some(Wrapped)
}
(Игнорируйте атрибут @frozen на данный момент. Мы обсудим его в разделе о замороженных и незамороженных перечислениях позже.)
Тип Result, целью которого является представление успеха или неудачи операции, имеет аналогичную структуру, но добавляет второе связанное значение (и соответствующий обобщенный параметр) для случая неудачи, позволяя ему захватывать подробную информацию об ошибке:
**@frozen enum** Result<Success, Failure: Error> {
/// Успех, хранящий значение `Success`.
case success(Success)
/// Неудача, хранящая значение `Failure`.
case failure(Failure)
}
Мы обсудим Result подробно в главе об обработке ошибок, и мы также будем использовать его в многих примерах в этой главе.
Вы создаете значение перечисления, указывая один из его случаев, плюс значения для связанных значений случая, если они есть:
let alignment = TextAlignment.left
let download: Result<String, NetworkError> = .success("<p>Hello world!</p>")
Обратите внимание, что во второй строке нам нужно предоставить полную аннотацию типа, включая все обобщенные параметры. Выражение вроде Result.success(htmlText) вызывает ошибку, если компилятор не может вывести конкретный тип другого обобщенного параметра, Failure, из контекста. Указав полный тип один раз, мы можем затем полагаться на вывод типов, используя синтаксис с ведущими точками. (Определение NetworkError здесь не показано.)
Enums Are Value Types Link to heading
Перечисления (enums) являются типами значений, так же как и структуры (structs). У них есть почти все те же возможности, что и у структур:
→ Перечисления могут иметь методы, вычисляемые свойства и сабскрипты.
→ Методы могут быть объявлены как изменяющие (mutating) или неизменяющие (non-mutating).
→ Вы можете писать расширения для перечислений.
→ Перечисления могут соответствовать протоколам.
Однако у перечислений не могут быть хранимые свойства. Состояние перечисления полностью представлено его вариантом (case) плюс связанное значение (associated value) этого варианта. Рассматривайте связанные значения как хранимые свойства для конкретного варианта.
Изменяющие методы в перечислениях работают так же, как и в структурах. Мы видели в главе о Структурах и Классах, что внутри изменяющего метода self передается по ссылке (inout) и, следовательно, может быть изменен. Поскольку у перечислений нет хранимых свойств и нет способа напрямую изменить связанное значение варианта, мы изменяем перечисление, присваивая новое значение непосредственно self.
Перечисления не требуют явных инициализаторов, потому что обычный способ инициализации переменной перечисления — это присвоение ей варианта. Однако возможно добавить дополнительные “удобные” инициализаторы в определение типа или в расширении. Например, используя API Locale из Foundation, мы можем добавить инициализатор в наше перечисление TextAlignment, который устанавливает значение по умолчанию для выравнивания текста для данной локали:
extension TextAlignment {
init(defaultFor locale: Locale) {
guard let language = locale.languageCode else {
// Значение по умолчанию, если язык не указан.
self = .left
return
}
switch Locale.characterDirection(forLanguage: language) {
case .rightToLeft:
self = .right
// Левое выравнивание по умолчанию для всего остального.
case .leftToRight, .topToBottom, .bottomToTop, .unknown:
self = .left
@unknown default:
self = .left
}
}
}
let english = Locale(identifier: "en_AU")
TextAlignment(defaultFor: english) // left
let arabic = Locale(identifier: "ar_EG")
TextAlignment(defaultFor: arabic) // right
(Мы рассмотрим случай @unknown default в разделе о Замороженных и Незапрещенных Перечислениях.)
Суммовые и произведенные типы Link to heading
Значение перечисления (enum) содержит ровно один из своих случаев (плюс значения для связанных с ним значений, если таковые имеются). На самом деле, перечисления изначально назывались “oneof”, а затем “union” в ранние дни Swift (до первого публичного релиза). Более конкретно, значение Result содержит либо значение успеха, либо значение ошибки, но никогда не оба (и никогда не ни одно). В отличие от этого, экземпляр типа записи содержит значения для всех своих полей: кортеж (String, Int) содержит строку и целое число. (Обратите внимание, что мы говорим о составных записях с более чем одним полем; UInt8 также является структурой, и можно сказать, что она ограничивает экземпляры “одним из 0…255”. Но это не то, что мы имеем в виду.)
Эта способность моделировать отношения “или” довольно уникальна, и именно она делает перечисления такими полезными. Она позволяет нам писать более безопасный и выразительный код, который в полной мере использует сильные типы в ситуациях, которые часто не могут быть выражены так же чисто с помощью структур, кортежей или классов.
Мы говорим “довольно уникальна”, потому что протоколы и наследование могут использоваться для той же цели, хотя с очень разными компромиссами и приложениями. Переменная типа протокола (также называемая экзистенциальной) может быть одним из любого типа, который соответствует протоколу. Аналогично, объект типа UIView на iOS также может ссылаться на любой из прямых или косвенных подклассов UIView, таких как UILabel или UIButton. При работе с таким объектом мы можем либо использовать общий интерфейс, определенный в базовом типе (эквивалентно вызову методов, определенных в перечислении), либо попытаться выполнить приведение типа экземпляра к конкретному подклассу, чтобы получить доступ к данным, уникальным для этого подкласса (эквивалентно переключению по перечислению).
Разница заключается в том, какой подход более распространен — либо динамическая диспетчеризация через общий интерфейс для протоколов и классов, либо переключение для перечислений — а также в конкретных возможностях и ограничениях, которые имеют эти конструкции. Например, список случаев перечисления фиксирован и не может быть расширен задним числом, в то время как вы всегда можете добавить еще один тип к протоколу или добавить другой подкласс (хотя наследование через границы модулей ограничено, если вы явно не объявите класс как open). Желательна ли эта свобода или даже необходима, зависит от решаемой задачи.
Как типы значений, перечисления также, как правило, более легковесны и лучше подходят для моделирования “обычных значений”.
Существует интересное соответствие между двумя категориями типов (“или” и “и”) и математическими концепциями сложения и умножения. Знание об этом не является обязательным для того, чтобы быть хорошим программистом на Swift, но мы находим это полезным в процессе проектирования пользовательских типов.
Существует множество возможных определений термина “тип”. Вот одно из них: тип — это множество всех возможных значений, или обитателей, которые могут принимать его экземпляры. Bool имеет два обитателя: false и true. UInt8 имеет 2^8 (256) обитателей. Int64 имеет 2^64 (примерно 18.4 квинтиллиона) обитателей. Типы, такие как String, имеют бесконечное количество обитателей — вы всегда можете создать еще одну строку, добавив еще один символ (по крайней мере, до тех пор, пока не заполните память вашего компьютера).
Теперь рассмотрим кортеж из двух логических полей: (Bool, Bool). Сколько обитателей у этого типа? Ответ — четыре: (false, false), (true, false), (false, true) и (true, true). Невозможно сконструировать какое-либо другое значение этого типа, кроме этих четырех. Что если мы добавим еще один Bool, сделав его (Bool, Bool, Bool)? Количество обитателей удваивается до восьми, поскольку каждый из предыдущих четырех обитателей может быть объединен с false и true соответственно. Это работает не только с Bool, конечно. Пара (Bool, UInt8) имеет 2×256=512 обитателей, потому что каждый из 256 обитателей UInt8 может быть объединен с одним из двух логических значений.
Говоря в общем, количество обитателей кортежа (или структуры, или класса) равно произведению обитателей его членов. По этой причине структуры, классы и кортежи также называются произведенными типами.
Сравните это с перечислениями. Вот перечисление с тремя случаями:
enum PrimaryColor {
case red
case yellow
case blue
}
Этот тип имеет три обитателя — по одному на случай. Невозможно сконструировать какое-либо другое значение PrimaryColor, кроме .red, .yellow или .blue. Что произойдет, если мы добавим связанные значения в смесь? Давайте добавим четвертый случай, который позволяет нам указать значение серого цвета между 0 (черный) и 255 (белый):
enum ExtendedColor {
case red
case yellow
case blue
case gray(brightness: UInt8)
}
Случай .gray сам по себе имеет 256 возможных значений, что приводит к 3 + 256 = 259 обитателям для всего перечисления. Говоря в общем, количество обитателей перечисления равно сумме обитателей его случаев. Вот почему перечисления также называются суммовыми типами.
Добавление поля в структуру умножает количество возможных состояний, часто в огромной степени. Добавление случая в перечисление добавляет только одного дополнительного обитателя (или, если случай имеет связанное значение, добавляет обитателей полезной нагрузки). Это полезное свойство для написания безопасного кода, и раздел “Проектирование с перечислениями” позже в этой главе охватывает, как воспользоваться этим свойством в нашем коде.
Сопоставление шаблонов Link to heading
Чтобы сделать что-то полезное с значением перечисления, нам часто нужно проверить его случай и извлечь связанное значение. Рассмотрим опционалы в качестве примера: каждая операция, связанная с опционалами — будь то связывание с помощью if let, опциональная цепочка или вызов Optional.map — является сокращением для распаковки связанного значения случая some и дальнейшей его обработки. Если проверяемое значение — none, операция обычно прерывается.
Наиболее распространенный способ проверки перечисления — это оператор switch, который позволяет сравнивать значение с несколькими кандидатами в одном выражении. В качестве дополнительного бонуса, switch имеет удобный синтаксис для сравнения значения с конкретным случаем и извлечения связанных значений за один раз; этот механизм называется сопоставлением шаблонов. Сопоставление шаблонов не является эксклюзивным для операторов switch, но они являются его наиболее заметным применением.
Сопоставление шаблонов полезно, потому что оно позволяет нам разбирать структуру данных по ее форме, а не только по содержимому. Возможность комбинировать чистое сопоставление с привязкой значений делает его особенно мощным.
Каждый случай в операторе switch начинается с одного или нескольких шаблонов, с которыми сопоставляется входное значение. Шаблон описывает структуру значения. Например, шаблон .success((42, _)) в следующем примере соответствует случаю успеха перечисления, где связанное значение — это пара, первый элемент которой равен 42. Подчеркивание является шаблоном подстановки — второй элемент пары может быть любым значением. В дополнение к простому сопоставлению, мы можем извлекать части составного значения и связывать их с переменными. Шаблон .failure(let error) соответствует случаю неудачи и связывает связанное значение с новой локальной константой error:
let result: Result<(Int, String), Error> = ...
switch result {
case .success((42, _)):
print("Найдено волшебное число!")
case .success(_):
print("Найдено другое число")
case .failure(let error):
print("Ошибка: \(error)")
}
Давайте рассмотрим типы шаблонов, которые поддерживает Swift.
Шаблон подстановки — Подчеркивание _ соответствует любому значению и игнорирует его. Шаблоны подстановки часто используются для игнорирования одной части связанного значения, в то время как сопоставляется другая. Мы видели пример этого выше с .success((42, _)). В операторах switch case _ эквивалентен ключевому слову default: оба соответствуют любому значению и имеют смысл только как последний случай оператора switch.
Шаблон кортежа — Этот шаблон сопоставляет кортежи с запятой, разделяющей список из нуля или более подшаблонов. Например, (let x, 0, _) соответствует кортежу с тремя элементами — где второй элемент равен 0 — и связывает первый элемент с x. Сам шаблон кортежа соответствует только структуре кортежа, т.е. значениям, разделенным запятыми и заключенным в скобки. Содержимое кортежа — это подшаблоны, которые сопоставляются отдельно (в этом примере — шаблон привязки значения, шаблон выражения и шаблон подстановки). Шаблоны кортежей полезны для переключения по нескольким значениям в одном операторе switch.
Шаблон случая перечисления — Этот шаблон соответствует указанному случаю перечисления. Шаблон может включать подшаблоны для связанных значений, будь то для проверки на равенство (.success(42)) или для привязки значений (.failure(let error)). Чтобы игнорировать связанное значение, используйте подчеркивание или полностью опустите шаблон, например, .success(_) и .success эквивалентны.
Шаблоны случаев перечисления — это единственный способ извлечь связанное значение перечисления или сопоставить его с случаем, игнорируя связанное значение. (Для сравнения с конкретным случаем с конкретным связанным значением вы также можете использовать == в операторе if, при условии, что перечисление является Equatable.)
Шаблон привязки значения — Этот шаблон связывает часть или все сопоставленное значение с новой константой или переменной. Синтаксис позволяет использовать let someIdentifier или var someIdentifier. Область видимости новой переменной — это блок case, в котором она появляется.
В качестве сокращения для множественных привязок значений в одном шаблоне вы можете предварить шаблон одним let, вместо того чтобы повторять let для каждой привязки. Шаблоны let(x, y) и (let x, let y) эквивалентны. Обратите внимание на тонкое различие при использовании привязки значений и сопоставления на равенство в одном шаблоне: шаблон (let x, y) связывает первый элемент кортежа с новой константой, но сравнивает второй элемент кортежа с существующей переменной y.
Чтобы объединить привязку значений с другими условиями, которые должны быть выполнены для связанных значений, вы можете расширить шаблон привязки значений с помощью условия where. Например,
.success(let httpStatus) where 200..<300 ~= httpStatus
соответствует только успешным значениям с связанным значением, которое попадает в указанный диапазон. Критически важно, что условие where оценивается после этапа привязки значений, поэтому мы можем использовать связанные переменные в условии where. (Для получения дополнительной информации о операторе сопоставления шаблонов ~= смотрите раздел о шаблонах выражений ниже.)
Если вы включаете несколько шаблонов в одном случае, все шаблоны должны использовать одни и те же имена и типы в своих привязках значений. Предположим, вы хотите переключаться по следующему перечислению:
enum Shape {
case line(from: Point, to: Point)
case rectangle(origin: Point, width: Double, height: Double)
case circle(center: Point, radius: Double)
}
Обратите внимание, что связанные значения каждого из случаев содержат начальную точку фигуры, но другие параметры варьируются в зависимости от типа фигуры. Тем не менее, возможно извлечь начальную точку фигуры с помощью одного оператора case, содержащего три шаблона:
switch shape {
case .line(let origin, _),
.rectangle(let origin, _, _),
.circle(let origin, _):
print("Начальная точка:", origin)
}
Вы не можете включать другие привязки значений в этом случае, например, для радиуса круга, потому что компилятор гарантирует, что каждая связанная переменная содержит допустимое значение, когда один из шаблонов соответствует. Поэтому компилятор должен быть в состоянии присвоить допустимое значение каждой переменной, и он не может сделать это для радиуса, если shape оказывается линией или прямоугольником.
Шаблон опционала — Это предоставляет синтаксический сахар для сопоставления и распаковки опциональных значений, используя знакомый синтаксис вопросительного знака. Шаблон let value? эквивалентен .some(let value), т.е. он соответствует, когда опционал не равен nil и связывает распакованное значение с константой.
Как мы видели в главе об опционалах, мы также можем использовать nil, чтобы сопоставить случай none опционала. Этот сокращенный синтаксис не требует никакой специальной магии компилятора. Он работает как обычный шаблон выражения (см. ниже), потому что стандартная библиотека включает перегрузку оператора ~= для сравнения опционалов с nil.
Шаблон приведения типов — Шаблон is SomeType соответствует, если тип значения в рантайме совпадает с указанным типом или является подклассом этого типа. let value as SomeType выполняет ту же проверку и дополнительно приводит сопоставленное значение к указанному типу, в то время как is просто проверяет тип:
let input: Any = ...
switch input {
case let integer as Int: ... // integer имеет тип Int.
case let string as String: ... // string имеет тип String.
default: fatalError("Неожиданный тип во время выполнения: \(type(of: input))")
}
Шаблон выражения — Этот шаблон сопоставляет с выражением, передавая входное значение и шаблон оператору сопоставления шаблонов ~=, который определен в стандартной библиотеке. Стандартная реализация ~= для типов Equatable перенаправляет на ==; так работают простые проверки на равенство в шаблонах.
Стандартная библиотека также предоставляет перегрузки ~= для диапазонов. Это позволяет использовать красивый синтаксис для проверки, попадает ли значение в диапазон, особенно в сочетании с односторонними диапазонами. Следующий оператор switch проверяет, является ли число положительным, отрицательным или нулем:
let randomNumber = Int8.random(in: .min...(.max))
switch randomNumber {
case ..<0: print("\(randomNumber) отрицательное")
case 0: print("\(randomNumber) равно нулю")
case 1...: print("\(randomNumber) положительное")
default: fatalError("Не может произойти")
}
Обратите внимание, что компилятор заставляет нас включить случай default, потому что он не может определить, что три конкретных случая охватывают все возможные входные данные (хотя они и охватывают), и операторы switch всегда должны быть исчерпывающими. Мы поговорим больше о проверке исчерпываемости в разделе “Проектирование с перечислениями”.
Мы можем расширить систему сопоставления шаблонов, перегрузив оператор ~= для наших пользовательских типов. Функция, реализующая ~= должна иметь следующую форму:
func ~=(pattern: ???, value: ???) -> Bool
Типы аргументов могут быть выбраны произвольно (они даже не обязательно должны быть одинаковыми). Компилятор выберет наиболее специфическую перегрузку, которая работает с типами входных значений. Для каждого шаблона выражения, с которым сталкивается компилятор, он оценивает выражение pattern ~= value, где value — это значение, по которому мы переключаемся, а pattern — это шаблон в операторе case. Сопоставление успешно, если выражение возвращает true.
Следует отметить, что, помимо использования в наших программах, мы никогда не находили необходимости расширять сопоставление шаблонов таким образом. Стандартная библиотека довольно хорошо охватывает основы, и все, что выходит за рамки основ, страдает от невозможности комбинировать пользовательское сопоставление шаблонов на основе ~= с привязкой значений и шаблонами подстановки.
СопоставлениеШаблоновВДругихКонтекстах Link to heading
Сопоставление шаблонов — это единственный способ извлечь связанное значение из перечисления (enum). Но сопоставление шаблонов не является эксклюзивным для перечислений, и оно не ограничивается только операторами switch. На самом деле, присваивание, такое как let x = 1, можно рассматривать как шаблон связывания значений на левой стороне оператора присваивания, соответствующий выражению на правой стороне. Другие примеры сопоставления шаблонов включают:
- Деструктуризация кортежей в присваиваниях, например,
let (word, pi) = ("Hello", 3.1415)— и в циклах, например,for (key, value) in dictionary {...}. Обратите внимание, что циклforне используетletдля указания связывания значений. По умолчанию все идентификаторы являются связыванием значений в этом случае. Циклыforтакже поддерживают условияwhere. Например,for n in 1...10 where n.isMultiple(of: 3) {...}выполняет тело цикла только для 3, 6 и 9.
Шаблоны кортежей могут быть вложенными для деструктуризации значений во вложенных кортежах. Пример: for (num, (key: k, value: v)) in dictionary.enumerated() {...}.
Использование подстановочных знаков для игнорирования значений, которые нас не интересуют. Например, for _ in 1...3 выполняет цикл три раза, не создавая переменную для счетчика цикла, а _=someFunction() подавляет предупреждение компилятора о “неиспользуемом результате”, когда мы хотим выполнить функцию ради ее побочных эффектов.
Обработка ошибок в блоке catch: do {...} catch let error as NetworkError {...}. Обратитесь к главе об обработке ошибок для получения дополнительной информации.
Операторы if case и guard case похожи на оператор switch, который имеет только один случай. Они иногда полезны, потому что требуют меньше строк, чем switch, хотя мы предпочитаем последний в многих ситуациях, чтобы воспользоваться проверками исчерпываемости компилятора.
Синтаксис if/guard case [let] часто является большой преградой для новичков в Swift. Мы считаем, что это связано с тем, что он использует оператор присваивания = для того, что по сути является операцией сравнения, и только опционально включает связывания значений. Например, следующий код проверяет, является ли перечисление конкретным случаем, но игнорирует связанное значение:
let color: ExtendedColor = ...
if case .gray = color {
print("Некоторый оттенок серого")
}
Вы можете рассматривать оператор присваивания как “выполнить сопоставление шаблона значения на правой стороне с шаблоном на левой стороне”. Это становится яснее, когда вы включаете связывание значений, которое использует тот же синтаксис, только добавляя let или var:
if case .gray(let brightness) = color {
print("Серый с яркостью \(brightness)")
}
Это не так уж и отличается от знакомого синтаксиса if let x = x, который используется для опционалов. На самом деле, создатель Swift Крис Латтнер сожалеет о том, что разработчики Swift решили добавить if case [let] вообще. Если бы синтаксис if let для опционалов использовал истинный опциональный шаблон, включая вопросительный знак (if let x? = x), язык мог бы просто принимать любой действительный шаблон в условиях if, и if case не был бы необходим.
Циклы for case и while case работают аналогично if case. Они позволяют выполнять цикл только тогда, когда сопоставление шаблона успешно. Обратитесь к главе об опционалах для примеров.
Наконец, списки аргументов в выражениях замыкания иногда выглядят как шаблоны, потому что они также поддерживают своего рода деструктуризацию кортежей. Например, мы можем применить функцию map к словарю и использовать список параметров (key, value) внутри замыкания преобразования, даже если функция, переданная в map, указана как имеющая единственный параметр Element (где Dictionary.Element является кортежем типа (Key, Value)):
dictionary.map { (key, value) in
...
}
Здесь (key, value) выглядит как кортеж, но на самом деле это список параметров функции с двумя элементами. Тот факт, что мы можем распаковать кортеж внутри списка параметров, благодаря специальной обработке в компиляторе, не связанной с сопоставлением шаблонов. Без этой функции нам пришлось бы использовать список параметров с одним элементом, такой как { element in ... }, а затем деструктурировать element (который теперь является реальным кортежем) в key и value на отдельной строке.
Проектирование с использованием перечислений (Enums) Link to heading
Поскольку перечисления (enums) принадлежат к другой категории типов, чем структуры (structs) и классы (classes), они поддаются различным шаблонам проектирования. И поскольку истинные суммы типов (true sum types) являются относительно редкой (хотя быстро растущей) особенностью среди основных языков программирования, вероятно, вы не так привыкли работать с ними, как с традиционными объектно-ориентированными подходами.
Итак, давайте рассмотрим некоторые из этих шаблонов, которые мы можем использовать в нашем коде, чтобы в полной мере воспользоваться преимуществами перечислений. Мы разделили их на шесть основных пунктов:
- Полное переключение (Switching exhaustively)
- Сделать невозможными недопустимые состояния (Making illegal states impossible)
- Моделирование состояния с помощью перечислений (Modeling state with enums)
- Выбор между перечислениями и структурами (Choosing between enums and structs)
- Проведение параллелей между перечислениями и протоколами (Drawing parallels between enums and protocols)
- Моделирование рекурсивных структур данных с помощью перечислений (Modeling recursive data structures with enums)
Switching Exhaustively Link to heading
В большинстве случаев switch — это просто более удобный синтаксис для оператора if-case с несколькими условиями elseif-case. Оставляя в стороне синтаксические различия, есть одно важное отличие: оператор switch должен быть исчерпывающим, т.е. его случаи должны охватывать все возможные входные значения. Компилятор это контролирует.
Проверка исчерпываемости — важный инструмент для написания безопасного кода и поддержания его корректности по мере изменения программ. Каждый раз, когда вы добавляете случай к существующему перечислению, компилятор может предупредить вас обо всех местах, где вы используете это перечисление и где необходимо обработать новый случай. Проверка исчерпываемости не выполняется для операторов if, и она не работает в операторах switch, которые включают случай по умолчанию — такой switch никогда не может быть не исчерпывающим, поскольку случай по умолчанию соответствует любому значению.
По этой причине мы рекомендуем избегать случаев по умолчанию в операторах switch, если это возможно. Вы не можете полностью избежать их, потому что компилятор не всегда достаточно умен, чтобы определить, является ли набор случаев действительно исчерпывающим. Мы видели пример этого выше, когда мы использовали switch для Int8, и наши шаблоны диапазона охватывали все возможные значения. Компилятор всегда ошибается на стороне безопасности, т.е. он никогда не сообщит о не исчерпывающем наборе шаблонов как об исчерпывающем (за исключением ошибок в реализации компилятора).
Ложные отрицания не являются проблемой при использовании switch для перечислений. Проверки исчерпываемости полностью надежны для следующих типов:
→ Bool
→ Перечисления, при условии, что любые связанные значения могут быть проверены исчерпывающе, или вы сопоставляете их с шаблонами, которые соответствуют любому значению (шаблон с подстановкой или привязкой значения)
→ Кортежи, при условии, что их типы членов могут быть проверены исчерпывающе
Давайте рассмотрим пример. Здесь мы используем switch для перечисления Shape, которое мы определили выше:
let shape: Shape = ...
switch shape {
case let .line(from, to) where from.y == to.y:
print("Горизонтальная линия")
case let .line(from, to) where from.x == to.x:
print("Вертикальная линия")
case .line(_, _):
print("Косая линия")
case .rectangle, .circle:
print("Прямоугольник или круг")
}
Мы включаем два условия where, чтобы рассматривать горизонтальные (равные координаты y) и вертикальные (равные координаты x) линии как специальные случаи. Эти два случая недостаточны для исчерпывающего охвата случая .line, поэтому нам нужен еще один случай, который поймает все оставшиеся линии. Хотя нас не интересует различие между .rectangle и .circle здесь, мы предпочитаем явно перечислить оставшиеся случаи, чем использовать случай по умолчанию, так как это позволяет нам воспользоваться проверкой исчерпываемости.
Кстати, компилятор также проверяет, что каждый шаблон в switch выполняет свою функцию. Компилятор выдаст предупреждение, если сможет доказать, что шаблон никогда не будет соответствовать, потому что он уже полностью охвачен одним или несколькими предыдущими шаблонами.
Проверка исчерпываемости приносит наибольшую пользу, если перечисление и код, который его использует, развиваются синхронно, т.е. каждый раз, когда случай добавляется в перечисление, код, который использует это перечисление, может быть обновлен одновременно. Это обычно верно, если у вас есть доступ к исходному коду зависимостей вашей программы, и программа и ее зависимости компилируются вместе. Ситуация усложняется, когда библиотека распространяется в бинарной форме, и программа, использующая библиотеку, должна быть готова работать с более новой версией библиотеки, чем та, которая была известна на момент компиляции программы. В этой ситуации может быть необходимо всегда включать случай по умолчанию, даже в иначе исчерпывающих switch. Мы вернемся к этому в разделе о замороженных и незамороженных перечислениях позже в этой главе.
Сделать невозможными незаконные состояния Link to heading
Существует множество веских причин для использования статически типизированных языков программирования, таких как Swift. Одной из них является производительность: чем больше компилятор знает о типах переменных в программе, тем быстрее он может сгенерировать код (в общем). Другой не менее важной причиной является то, что типы могут направлять разработчиков в том, как должны использоваться API. Если вы передаете значение неправильного типа в функцию, компилятор сразу же выдаст ошибку. Мы могли бы назвать это разработкой, управляемой компилятором — рассматривая компилятор не как врага, с которым нужно бороться, а как инструмент, который, используя информацию о типах, почти волшебным образом ведет вас к правильному решению:
→ Функция с тщательно выбранными входными и выходными типами позволяет меньше пространства для неправильного использования, поскольку типы устанавливают «верхнюю границу» для поведения функции. Например, если вы реализуете функцию, которая принимает необязательный объект в качестве параметра, вы можете быть уверены, что объект никогда не будет равен nil внутри тела функции. Насколько хорошо это работает, зависит от того, насколько точно мы можем ограничить наши типы, чтобы принимать только допустимые значения. Перечисления часто являются идеальным инструментом для точного определения диапазона допустимых значений.
→ Статически проверяемые типы предотвращают определенные категории ошибок на этапе компиляции; код, который не компилируется из-за нарушения ограничений, установленных системой типов, никогда не придется обрабатывать во время выполнения.
→ Типы служат документацией, которая никогда не выходит из синхронизации. В отличие от комментариев, которые люди могут забыть обновить, когда код изменяется, типы являются неотъемлемой частью программы и всегда актуальны.
Конечно, не каждый аспект можно выразить в системе типов. Например, Swift не предоставляет поддержки для передачи информации о том, что функция является чистой (т.е. не имеет побочных эффектов) или каковы ее характеристики производительности. Вот почему нам все еще нужна документация, и разработчики должны следить за тем, чтобы не нарушать задокументированные гарантии при обновлении существующего кода. Но должно быть очевидно, что количество помощи, которую вы можете получить от компилятора, растет с возможностями системы типов. (Следует отметить, что, безусловно, возможно, что язык программирования может переусердствовать. Предоставление компилятору большего количества информации требует больше работы от разработчика, что, хотя и часто полезно, может иногда мешать решению фактической задачи. Плюс, чем точнее ваши типы настроены для конкретного случая использования, тем больше кода вам нужно написать для преобразования значений между типами. Мы не думаем, что Swift достиг этой точки, но бесплатного сыра не бывает.)
Вот наша рекомендация по проектированию пользовательских типов, чтобы максимизировать помощь, которую вы получаете от компилятора: используйте типы, чтобы сделать незаконные состояния непредставимыми. Ранее мы видели в разделе о Суммарных и Продуктовых Типах, что добавление случая в перечисление добавляет ровно одно возможное значение в тип. Вы не можете сделать это более детализированным, что делает перечисления очень полезными для этой цели.
Каноническим примером является Optional, который через свой случай none добавляет единственного жителя к обернутому типу. Это именно то, что нужно для представления отсутствия значения без обращения к сигнальным значениям. Мы обсуждали проблему с сигнальными значениями в главе о Опционалах.
Давайте рассмотрим API, который сложнее использовать, чем должен быть, потому что он не следует вышеуказанным рекомендациям. Общим шаблоном для асинхронных операций (таких как выполнение сетевого запроса) в SDK iOS от Apple является передача обработчика завершения (функции обратного вызова) в метод, который вы вызываете. Метод затем вызовет обработчик, когда задача завершится, передавая результат операции. Поскольку большинство асинхронных операций могут завершиться неудачей, результат обычно может быть либо некоторым значением, указывающим на успех, например, ответом сервера, либо ошибкой. В будущем мы, вероятно, увидим меньше методов, которые принимают обратные вызовы, и вместо этого увидим больше асинхронных методов. Но на данный момент они все еще распространены.
Рассмотрим API геокодирования в фреймворке CoreLocation от Apple. Вы передаете ему строку адреса и функцию обратного вызова. Геокодер связывается с сервером, который возвращает соответствующие объекты placemark для адреса. Затем геокодер вызывает обработчик завершения с placemarks или ошибкой:
class CLGeocoder {
func geocodeAddressString(_ addressString: String,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void) {
// ...
}
}
Обратите внимание на тип обработчика завершения, ([CLPlacemark]?, Error?) -> Void. Его два параметра оба являются опциональными. Это означает, что есть четыре возможных состояния, которые эта функция может вернуть вызывающему: (.some, .none), (.none, .some), (.some, .some) или (.none, .none). (Это упрощенный взгляд; состояния .some действительно имеют бесконечно много возможных значений, но нас интересует только то, равны ли они nil или не равны nil.) Проблема в том, что из четырех законных состояний только первые два имеют смысл на практике. Что разработчику делать, если он получает массив placemarks и ошибку? Еще хуже, что если оба значения возвращаются nil? Компилятор не может помочь вам здесь, потому что типы менее точны, чем должны быть.
Теперь Apple, вероятно, позаботилась о том, чтобы при реализации этого метода никогда не возвращать одно из этих недопустимых состояний, так что они никогда не возникнут на практике. Но пользователи API не могут быть уверены в этом, и даже если это правда сегодня, нет гарантии, что это будет верно в следующем релизе SDK.
API геокодирования был бы гораздо более удобным для разработчиков, если бы он заменил два опционала на значение Result<[CLPlacemark], Error>:
extension CLGeocoder {
func geocodeAddressString(_ addressString: String,
completionHandler: @escaping (Result<[CLPlacemark], Error>) -> Void) {
// ...
}
}
Тип Result представляет либо успех, либо неудачу, но никогда не оба и никогда не none. Используя тип, который делает недопустимые состояния непредставимыми, API становится проще в использовании, и целый ряд потенциальных ошибок просто не может произойти, потому что компилятор не позволяет им возникнуть. Многие API iOS от Apple не используют все преимущества системы типов Swift, потому что они написаны на Objective-C, который не имеет эквивалентного концепта для перечислений с ассоциированными значениями. Но это не значит, что мы не можем сделать лучше в Swift.
Начиная с iOS 15 и macOS 12, CLGeocoder также предоставляет асинхронный API, который либо возвращает [CLPlacemark], либо выбрасывает ошибку. Этот новый API еще более точен, чем наш переписанный пример выше, потому что мы знаем, что он всегда либо вернет значение, либо выбросит ошибку, в то время как API, который принимает обработчик завершения, может вызвать обработчик ноль, один или более раз.
При написании функции тщательно подумайте о типах параметров и возвращаемых значениях. Чем точнее вы можете ограничить типы набором допустимых входных и выходных значений, тем больше помощи компилятор может вам предоставить (при реализации функции) и пользователям API (при его вызове).
Кстати, есть еще одно интересное состояние в API геокодирования, которое мы игнорировали до сих пор: что если возвращаемый массив placemarks пуст? Документация, похоже, говорит, что это никогда не должно происходить, т.е. если сервер не может найти соответствие для входной строки, функция вернет ошибку. Но есть и другое возможное толкование: пустой массив может сигнализировать о том, что запрос сам по себе был успешным (нет сетевой ошибки и т.д.), но соответствие не было найдено. Просто глядя на типы, мы не можем быть уверены, какое толкование является правильным. Если бы мы хотели закодировать первое толкование в системе типов, нам нужен был бы тип массива, который предоставлял бы гарантию на этапе компиляции, что он никогда не будет пустым. Стандартная библиотека этого не предоставляет, но мы могли бы написать свой собственный. Основная идея заключается в том, чтобы написать структуру, которая использует отдельное свойство для первого элемента массива (называемого head) и стандартный массив (который может быть пустым) для остальных элементов (называемого tail):
struct NonEmptyArray<Element> {
private var head: Element
private var tail: [Element]
}
Поскольку head является не опциональным, невозможно создать значение NonEmptyArray, которое не содержит хотя бы одного элемента. Полная реализация NonEmptyArray должна соответствовать всем тем же протоколам, которые принимает Array, наиболее заметно Collection. Это сделает его удобным в использовании как обычный массив — иногда даже более удобным, потому что мы можем перегружать некоторые API Collection, такие как first и last, чтобы возвращать не опциональные значения. Если вы хотите продолжить это, ознакомьтесь с библиотекой NonEmpty, написанной Брендоном Уильямсом и Стивеном Селисом. Это реализация этого паттерна, которая является обобщенной для обернутого типа коллекции (так что вы также можете иметь непустую строку, например). А для более глубокого обсуждения протоколов коллекций Swift смотрите главу о Протоколах Коллекций.
Моделирование состояния с помощью перечислений Link to heading
Мы можем применить эту цель сделать недопустимые значения не представимыми к другому важному аспекту проектирования приложений: как моделировать состояние в наших программах. Состояние программы — это содержимое всех переменных в данный момент времени, плюс (неявно) текущее состояние выполнения, т.е. какие потоки выполняются и какую инструкцию они выполняют. Состояние «помнит» такие вещи, как, в каком режиме находится приложение, какие данные оно отображает, какую пользовательскую интеракцию оно в данный момент обрабатывает и так далее. Все, кроме самых тривиальных программ, имеют состояние: что произойдет дальше, когда выполняется определенная инструкция, зависит от текущего состояния, в котором находится система. (HTTP является примером протокола без состояния, что означает, что серверы должны обрабатывать HTTP-запросы, не учитывая предыдущие запросы от того же клиента. Веб-разработчики должны использовать такие функции, как куки, чтобы запомнить состояние между несколькими запросами. Но даже если HTTP без состояния, программа, обрабатывающая HTTP-запросы, все равно будет иметь состояние, чтобы поддерживать свое внутреннее состояние.)
По мере выполнения программы она изменяет свое состояние в ответ на внешние события, такие как взаимодействия с пользователем или входящие данные из сети. Это может происходить неявно, без особого внимания со стороны разработчика — в конце концов, изменения состояния происходят все время. Но по мере усложнения приложения разумно сделать сознательные усилия для определения возможных состояний, в которых может находиться программа (или одна из ее подсистем), а также допустимых переходов между состояниями. Набор состояний, в котором может находиться система, также известен как ее пространство состояний.
Старайтесь сделать пространство состояний вашей программы как можно меньше. Чем меньше пространство состояний, тем легче ваша работа как разработчика — меньшее пространство состояний уменьшает количество случаев, с которыми ваш код должен иметь дело. Поскольку перечисления моделируют конечное количество состояний, они идеально подходят для моделирования состояния и переходов между состояниями. И поскольку каждое состояние или случай перечисления несет свои собственные данные (в виде связанных значений), легко сделать недопустимые комбинации состояний не представимыми, как мы видели в предыдущем разделе. (Следует отметить, что пространство состояний вашей программы технически может быть бесконечно большим, особенно если вы принимаете пользовательский ввод в виде текста или загруженных изображений и т.д. Эти типы данных естественно имеют [почти] бесконечное количество «обитателей». Но, как и в предыдущем разделе, где нас интересовало, является ли значение nil или не nil, это обычно не проблема. Существенные части состояния большинства систем, как правило, конечны и часто немногочисленны; в противном случае мы не смогли бы смоделировать их в коде.)
Давайте рассмотрим пример. Предположим, мы пишем приложение для чата. Когда пользователь открывает канал чата, приложение должно отображать индикатор загрузки, пока загружает список сообщений из сети. Когда сетевой запрос завершается, пользовательский интерфейс переходит либо к отображению сообщений, либо к показу ошибки, если запрос не удался. Сначала давайте рассмотрим, как мы бы смоделировали состояние приложения традиционным способом без перечислений (технически мы все равно используем перечисления, потому что будем использовать опционалы, но вы поняли идею). Мы могли бы использовать три переменные — логическое значение, которое мы устанавливаем в true, пока сетевой запрос выполняется, и два опционала для списка сообщений и ошибки соответственно:
struct StateStruct {
var isLoading: Bool
var messages: [Message]?
var error: Error?
}
// Установить начальное состояние.
var structState = StateStruct(isLoading: true, messages: nil, error: nil)
Оба значения messages и error должны быть nil во время загрузки, а затем одно из них получает значение, когда сетевой запрос завершается. Они не должны оба быть не nil одновременно, и isLoading не должен быть true, пока одно из них не является не nil. Вспомните наше обсуждение о типах-суммах и типах-продуктах и о том, как определить количество обитателей у типа. Структура StateStruct является типом-продуктом, который имеет 2×2×2=8 возможных состояний: любая комбинация true или false для логического значения и none или some для любого из двух опционалов (снова мы игнорируем бесконечно много состояний для некоторых случаев, потому что они не имеют отношения к этому обсуждению). Это проблема, потому что наш аппу нужно обрабатывать только три из этих восьми состояний: загрузка, отображение списка сообщений или отображение ошибки. Остальные пять — это недопустимые комбинации, которые не должны возникать, если мы правильно написали нашу программу, но мы не можем ожидать, что компилятор предупредит нас, если мы создадим недопустимое состояние.
Теперь давайте смоделируем наше состояние как пользовательское перечисление с тремя состояниями: loading, loaded и failed:
enum StateEnum {
case loading
case loaded([Message])
case failed(Error)
}
// Установить начальное состояние.
var enumState = StateEnum.loading
Вы сразу заметите, что установка начального состояния становится гораздо более чистой, потому что нам не нужно беспокоиться о свойствах, которые не имеют отношения к начальному состоянию. Более того, мы полностью устранили возможность оказаться в недопустимом состоянии. Поскольку каждое состояние несет свои собственные связанные данные, связанные значения для loaded и failed не должны быть опциональными. В результате невозможно перейти в состояние failed, если у нас на самом деле нет значения Error в нашем коде. (Состояние loaded немного менее ясно, потому что вы всегда можете присвоить пустой массив, но это не то, что вы, вероятно, сделаете случайно.) Когда наша программа находится в определенном состоянии, мы можем быть уверены, что все необходимые данные для этого состояния также доступны. Наше перечисление StateEnum служит основой для конечного автомата.
Перечисления не являются полными конечными автоматами, потому что им не хватает возможности указывать недопустимые переходы состояний — например, в нашем простом примере не должно быть возможности перейти от loaded к failed или наоборот. На практике, невозможность инстанцировать состояние, если у вас нет действительных значений для всех связанных данных, почти так же хороша: в хорошо спроектированной программе маловероятно, что вы найдете много мест в коде, где все связанные данные для состояния доступны, но переход к этому состоянию все равно будет недопустимой операцией.
Каждый раз, когда наш код должен получить доступ к некоторым данным, зависящим от состояния (например, массиву сообщений), мы теперь вынуждены переключать перечисление состояния, чтобы извлечь связанные значения. Это иногда может показаться неудобным, потому что синтаксис switch довольно громоздкий. Но это важная функция безопасности, потому что она заставляет нас всегда обрабатывать каждое возможное состояние — по крайней мере, если мы соблюдаем рекомендацию не использовать случаи по умолчанию в наших операторах switch.
Кстати, вы могли заметить, что структура, с которой мы начали, и перечисление, которым мы ее заменили, не являются единственными способами моделирования этой части состояния. На самом деле, свойство StateStruct.isLoading избыточно, потому что в нашем дизайне isLoading должно быть true, если и только если messages и error оба равны nil. Мы могли бы сделать isLoading вычисляемым свойством, не теряя ничего:
struct StateStruct2 {
var messages: [Message]?
var error: Error?
var isLoading: Bool {
get { return messages == nil && error == nil }
set {
messages = nil
error = nil
}
}
}
Это уменьшает количество возможных состояний с восьми до четырех, оставляя только одно недопустимое состояние (когда messages и error не равны nil) — не идеально, но лучше, чем то, с чего мы начали. Часто трудно заметить такие избыточные свойства, но именно здесь связь между обитателями типа и алгеброй может действительно помочь нам. Если, как в этом примере, мы определяем, что наш пользовательский тип имеет 2×2×2 обитателя, но только три из них действительны, легко увидеть, что один из факторов избыточен: 2×2 достаточно места для трех состояний, поэтому должно быть возможно устранить один компонент.
Шаблон наличия двух взаимно исключающих опциональных значений также может напомнить вам о примере, который мы использовали в предыдущем разделе, где мы заменили ([CLPlacemark]?, Error?) на Result<[CLPlacemark], Error>. Применение того же шаблона к нашему примеру дало бы Result<[Message], Error>, но обратите внимание, что две ситуации не совсем идентичны; приложение для чата требует третьего состояния, «loading», где messages и error оба равны nil. Вложение Result в опционал достигает этого (вспомните, что обертывание типа в опционал всегда добавляет ровно одного обитателя), что приводит к этой альтернативной репрезентации нашего состояния:
/// nil означает "loading."
typealias State2 = Result<[Message], Error>?
Это эквивалентно нашему пользовательскому перечислению, т.е. это тип с тремя состояниями и теми же полезными нагрузками для состояний. (Result<[Message]?, Error> был бы еще одним эквивалентным вариантом.) Но семантически это, возможно, более слабое решение, потому что не сразу ясно, что nil обозначает состояние «loading».
Наш пример моделирует только состояние одной подсистемы нашего приложения как перечисление. Но вы можете продвинуть этот шаблон гораздо дальше и смоделировать состояние всей вашей программы как одно перечисление — обычно одно, которое имеет много вложенных перечислений и структур, которые разбивают состояние на отдельные подсистемы. Идея состоит в том, чтобы иметь одну переменную, которая захватывает состояние программы в целом. Все изменения состояния проходят через эту одну переменную, которую вы затем можете наблюдать (например, используя didSet), чтобы обновить пользовательский интерфейс вашего приложения, когда происходит изменение состояния. Этот дизайн также упрощает запись всего состояния приложения на диск и его чтение при следующем запуске, по сути, предоставляя вам восстановление состояния бесплатно. Если вы хотите узнать больше об этом, ознакомьтесь с нашей книгой «Архитектура приложений», которую Крис и Флориан написали вместе с Мэттом Галлахером.
Хотя вы можете смоделировать состояние всего вашего приложения как перечисление, что хорошо в шаблоне «перечисления как состояние», так это то, что вам не нужно полностью погружаться, чтобы извлечь из этого выгоду. Вы можете начать с малого, преобразовав одну подсистему (например, один экран) и посмотреть, как это работает. Затем постепенно поднимайтесь по иерархии, оборачивая ваши перечисления состояния для подсистем в новое перечисление, которое имеет один случай на подсистему.
В заключение, перечисление — это отличное решение для моделирования состояния. Оно может в значительной степени предотвратить недопустимые состояния, а хранение всего состояния для подсистемы (или даже для всей программы) в одной переменной делает переходы состояния гораздо менее подверженными ошибкам. Более того, исчерпывающие переключения позволяют компилятору указывать пути кода, которые необходимо обновить, когда вы добавляете новые состояния или изменяете их связанные значения.
Выбор между перечислениями и структурами Link to heading
Ранее в этой главе мы обсудили, как перечисления и структуры имеют очень разные свойства: значение перечисления представляет собой ровно один из его случаев (плюс связанные с ним значения), тогда как значение структуры представляет собой значения всех ее свойств. Несмотря на эти различия, не редкость сталкиваться с задачами, которые можно решить как с помощью перечисления, так и с помощью структуры.
Используя пример, вдохновленный постом в блоге Мэтта Диепхауса, мы создадим тип данных для аналитического события, используя перечисление и структуру. Вот вариант с перечислением:
enum AnalyticsEvent {
case loginFailed(reason: LoginFailureReason)
case loginSucceeded
... // больше случаев.
}
Это перечисление затем расширяется несколькими вычисляемыми свойствами, которые переключаются по перечислению и возвращают данные, необходимые пользователям этого типа, т.е. фактические строки и словари, которые должны быть отправлены на сервер:
extension AnalyticsEvent {
var name: String {
switch self {
case .loginSucceeded:
return "loginSucceeded"
case .loginFailed:
return "loginFailed"
// ... больше случаев.
}
}
var metadata: [String: String] {
switch self {
// ...
}
}
}
В качестве альтернативы мы могли бы смоделировать то же аналитическое событие как структуру, храня ее имя и метаданные в двух свойствах. Мы предоставляем статические методы (которые соответствуют случаям перечисления выше) для создания экземпляров для конкретных событий:
struct AnalyticsEvent {
let name: String
let metadata: [String: String]
private init (name: String, metadata: [String: String] = [:]) {
self.name = name
self.metadata = metadata
}
static func loginFailed(reason: LoginFailureReason) -> AnalyticsEvent {
return AnalyticsEvent(
name: "loginFailed",
metadata: ["reason": String(describing: reason)]
)
}
static let loginSucceeded = AnalyticsEvent(name: "loginSucceeded")
// ...
}
Поскольку мы объявили инициализатор как приватный, публичный интерфейс идентичен варианту с перечислением: где перечисление открывает случаи, такие как .loginFailed(reason:) или .loginSucceeded, структура открывает статические методы или свойства. Свойства name и metadata доступны в обоих вариантах, либо как вычисляемые свойства (в перечислении), либо как хранимые свойства (в структуре).
Тем не менее, каждая версия типа AnalyticsEvent имеет свои отличительные характеристики, которые могут стать преимуществами или недостатками в зависимости от ваших требований:
→ Если мы сделаем инициализатор структуры внутренним или публичным, структуру можно будет расширить в других файлах или даже других модулях с дополнительными статическими методами или свойствами, тем самым добавляя новые аналитические события в API. Это невозможно с вариантом перечисления: вы не можете ретроактивно добавлять новые случаи в перечисление.
→ Перечисление более точно моделирует данные; оно может представлять только один из своих предопределенных случаев, в то время как структура потенциально может представлять бесконечное количество значений в своих двух свойствах. Точность и безопасность перечисления полезны, если вы хотите выполнять дополнительную обработку событий, например, объединять последовательности событий.
→ Структура может иметь приватные «случаи» (т.е. статические методы или статические свойства, которые не видны всем клиентам), в то время как случаи перечисления всегда имеют такую же видимость, как и само перечисление.
→ Вы можете исчерпывающе переключаться по перечислению, что гарантирует, что вы не пропустите тип события. Но из-за строгости переключения по перечислению добавление дополнительного типа события в перечисление может стать потенциально разрушающим изменением для пользователей этого API, в то время как вы можете добавлять статические методы для новых типов событий в структуру, не затрагивая другой код.
Проведение параллелей между перечислениями и протоколами Link to heading
На первый взгляд, перечисления и протоколы могут не казаться чем-то общим. Но на самом деле между ними есть интересные параллели. В разделе о суммарных и произведенных типах мы упоминали, что перечисления не являются единственной конструкцией, которая может выражать отношения “один из”; для этой цели также могут использоваться протоколы. В этом разделе мы рассмотрим пример этого и обсудим различия между двумя подходами.
Начнем с типа, который мы использовали ранее в этой главе — перечисления для моделирования различных форм в приложении для рисования:
enum Shape {
case line(from: Point, to: Point)
case rectangle(origin: Point, width: Double, height: Double)
case circle(center: Point, radius: Double)
}
Фигура может быть линией, прямоугольником или кругом. Чтобы отобразить эти фигуры в контексте Core Graphics, мы добавляем метод рендеринга в расширение. Реализация должна переключаться по self и выполнять соответствующие команды рисования для каждого случая:
extension Shape {
func render(into context: CGContext) {
switch self {
case let .line(from, to): // ...
case let .rectangle(origin, width, height): // ...
case let .circle(center, radius): // ...
}
}
}
В качестве альтернативы мы можем использовать протокол для определения фигуры как любого типа, который может отрисовать себя в контексте Core Graphics:
protocol Shape {
func render(into context: CGContext)
}
Типы фигур, которые мы выразили как случаи перечисления выше, теперь становятся конкретными типами, которые соответствуют протоколу Shape. Каждый соответствующий тип реализует свой собственный метод render(into:):
struct Line: Shape {
var from: Point
var to: Point
func render(into context: CGContext) { /* ... */ }
}
struct Rectangle: Shape {
var origin: Point
var width: Double
var height: Double
func render(into context: CGContext) { /* ... */ }
}
// Тип `Circle` опущен.
Хотя функционально эквивалентные, интересно рассмотреть, как эти два подхода, использующие либо перечисления, либо протоколы, организованы и как они могут быть расширены с новой функциональностью. Реализация на основе перечислений сгруппирована по методам: код рендеринга на основе CGContext для всех типов фигур находится внутри одного оператора switch в методе render(into:). Реализация на основе протоколов, с другой стороны, сгруппирована по “случаям”: каждый конкретный тип фигуры реализует свой собственный метод render(into:), который содержит его специфический код рендеринга.
Это имеет важные последствия в терминах расширяемости: с вариантом перечисления мы можем легко добавлять новые методы рендеринга — например, для рендеринга в файл SVG — в расширении Shape позже, даже в другом модуле. Однако мы не можем добавлять новые виды фигур в перечисление, если не контролируем исходный код, содержащий объявление перечисления. И даже если мы можем изменить определение перечисления, добавление нового случая является изменением, нарушающим совместимость для всех методов, которые переключаются по этому перечислению.
С другой стороны, мы можем легко добавлять новые виды фигур с вариантом протокола: мы просто создаем новую структуру и соответствуем протоколу Shape. Однако, кроме как модифицируя оригинальный протокол Shape, мы не можем добавлять новые виды методов рендеринга, потому что не можем добавлять новые требования к протоколу вне объявления протокола. (Мы можем добавлять новые методы в протокол в расширении, но, как мы увидим в главе о протоколах, методы расширения часто не подходят для добавления новой функциональности в протокол, потому что они не динамически вызываются.)
Оказывается, что перечисления и протоколы имеют взаимодополняющие сильные и слабые стороны в этом сценарии. Каждое решение расширяемо в одном измерении и лишено гибкости в другом. Эти различия в расширяемости между перечислениями и протоколами менее важны, если объявление API и его использование происходят в одном и том же модуле. Однако, если вы пишете библиотечный код, вам следует рассмотреть, какое измерение расширяемости более важно: добавление новых случаев или добавление новых методов.
Если вас интересует эта конкретная проблема расширяемости через границы модулей, посмотрите эпизоды SwiftTalk, которые мы записали с Брендоном Кейсом на эту тему. В этих эпизодах мы исследуем технику, которая позволяет нам получить расширяемость по обоим измерениям одновременно.
Моделирование рекурсивных структур данных с помощью перечислений Link to heading
Перечисления идеально подходят для моделирования рекурсивных структур данных, т.е. структур данных, которые “содержат” сами себя. Подумайте о древовидной структуре: дерево имеет множество ветвей, где каждая ветвь является другим деревом, которое снова делится на несколько поддеревьев, и так далее, пока не достигнете листьев. Многие распространенные форматы данных имеют древовидную структуру, например, HTML, XML и JSON.
В качестве примера рекурсивной структуры данных давайте реализуем небольшой подмножество XML. Для полноценной реализации смотрите Swim или swift-html. Мы создадим перечисление Node, которое будет либо текстовым узлом, либо элементом, либо фрагментом (несколько узлов). Отношение “либо-либо” является сильным намеком на то, что сумма типов, т.е. перечисление, хорошо подходит для определения типа для этой структуры данных:
enum Node: Hashable {
case text(String)
indirect case element(
name: String,
attributes: [String: String] = [:],
children: Node = .fragment([]))
case fragment([Node])
}
Обратите внимание на ключевое слово indirect, которое необходимо для компиляции. indirect указывает компилятору представлять случай element как ссылку, и это позволяет рекурсии работать. Без того, чтобы случай element был ссылкой, перечисление имело бы бесконечный размер. Мы поговорим больше о indirect в следующем разделе.
Определение выше — это все, что нам нужно, чтобы построить простое дерево узлов, аналогичное HTML-коду <h1>Hello<em>World</em></h1>:
let header: Node = .element(name: "h1", children: .fragment([
.text("Hello "),
.element(name: "em", children: .text("World"))
]))
Теперь мы добавим несколько удобных соответствий и метод, чтобы упростить создание узлов Node. Сначала мы можем сделать наш тип соответствующим ExpressibleByArrayLiteral, чтобы упростить создание фрагментов:
extension Node: ExpressibleByArrayLiteral {
init(arrayLiteral elements: Node...) {
self = .fragment(elements)
}
}
Аналогично, мы можем сделать соответствие ExpressibleByStringLiteral, чтобы упростить создание текстовых узлов:
extension Node: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self = .text(value)
}
}
Наконец, давайте упростим оборачивание узлов в элементы, добавив метод расширения. Метод wrapped возвращает self, но обернутый внутри узла элемента:
extension Node {
func wrapped(in elementName: String, attributes: [String: String] = [:]) -> Node {
.element(name: elementName, attributes: attributes, children: self)
}
}
С учетом этих трех расширений, мы теперь можем записать пример изначально гораздо короче:
let contents: Node = [
"Hello ",
("World" as Node).wrapped(in: "em")
]
let headerAlt = contents.wrapped(in: "h1")
Чтобы сделать это еще более привычным для Swift, мы могли бы использовать строители результатов, что даст нам синтаксис, похожий на SwiftUI.
Мы упомянули в начале этой главы, что перечисления также могут иметь изменяющие методы. Например, мы могли бы написать изменяющую версию wrapped, которая изменяет self на месте:
extension Node {
mutating func wrap(in elementName: String, attributes: [String: String] = [:]) {
self = .element(name: elementName, attributes: attributes, children: self)
}
}
var greeting: Node = "Hello"
greeting.wrap(in: "strong")
Как и в случае со структурами, mutating не изменяет само значение: он только изменяет то, на какое значение указывает переменная.
Перечисления в Swift также могут использоваться для моделирования более абстрактных структур данных: например, довольно просто построить связный список, бинарное дерево или даже постоянные структуры данных. При этом стоит отметить, что крайне сложно превзойти встроенные структуры данных Swift — такие как массивы, словари или множества — для большинства случаев использования.
Косвенный Link to heading
Чтобы понять, почему мы сделали наш рекурсивный перечисляемый тип Node косвенным, вспомним, что перечисления являются типами значений. А типы значений не могут содержать сами себя, поскольку это создало бы бесконечную рекурсию при вычислении размера типа. Компилятор должен иметь возможность определить фиксированный и конечный размер для каждого типа. Обработка рекурсивного случая как ссылки, выделенной в куче, решает эту проблему, поскольку ссылка добавляет уровень косвенности; компилятор знает, что размер хранения для любой ссылки всегда составляет 8 байт (на 64-битной системе).
Обратите внимание, что нам не нужно было писать indirect перед случаем fragment, хотя он также использует Node рекурсивно. Это связано с тем, что связанное значение является массивом, который имеет постоянный размер (фактическая память для массива хранится в буфере).
Синтаксис indirect доступен только для перечислений. Если бы он не был доступен, или если бы мы хотели смоделировать рекурсивную структуру, мы могли бы воспроизвести то же поведение, обернув рекурсивное значение в класс, тем самым создавая косвенность вручную. Для нашего перечисления Node, если мы не хотим разрешать верхнеуровневые фрагменты, мы также могли бы предоставить альтернативное определение. Это не нужно помечать как indirect, потому что оно зависит от косвенности массива:
enum NodeAlt {
case text(String)
case element(name: String, attributes: [String: String], children: [Node])
}
Мы также можем добавить indirect к самому объявлению перечисления, т.е. indirect enum Node { ... }. Это более короткий синтаксис для включения косвенности для всех случаев, которые имеют связанное значение ( indirect применяется только к связанным значениям и никогда к битам тегов, которые перечисление использует для различения своих случаев). Если у косвенного случая есть несколько связанных значений, ссылка помещается вокруг объединенных связанных значений.
Размер значения перечисления равен размеру самого большого случая плюс размер, необходимый для хранения тега (какой из случаев представляет значение). Например, размер следующего перечисления составляет 17, что является размером самого большого случая (16) плюс байт для хранения тега (1):
enum TwoInts {
case nothing
case int(Int, Int)
}
MemoryLayout<TwoInts>.size // 17
Если мы пометим случай int как indirect, размер изменится на 8, а не на 9. Хотя размер ссылки составляет 8 байт, у ссылки есть некоторые неиспользуемые биты, в которые могут быть встроены биты тегов.
Иногда, когда у вас есть перечисление с большим случаем, вы можете захотеть пометить этот конкретный случай как indirect, чтобы уменьшить размер перечисления. Конечно, это происходит за счет косвенности.
В теории компилятор мог бы вывести, следует ли помечать перечисление как indirect. В некоторых других языках (например, Haskell) это делается за нас. Дизайнеры Swift выбрали не делать этого, потому что они хотят предоставить нам явный контроль, например, помечая большой случай как indirect. Кроме того, компилятор не может надежно вывести это: для обобщенного перечисления, следует ли помечать его как indirect, может зависеть от обобщенных параметров.
Сырые значения Link to heading
Иногда бывает желательно ассоциировать каждый случай перечисления (enum) с числом или каким-либо другим значением. Перечисления в C или Objective-C работают так по умолчанию — они на самом деле просто целые числа “под капотом”. Перечисления в Swift не могут быть взаимозаменяемыми с произвольными целыми числами, но мы можем опционально объявить одно-ко-многим соответствие между случаями перечисления и так называемыми сырыми значениями. Это может быть полезно для взаимодействия с API или для кодирования значения перечисления в формате данных, таком как JSON (система Codable, которую мы обсудим в главе о кодировании и декодировании, использует сырое значение для синтеза соответствия Codable для перечислений с сырыми значениями).
Чтобы дать перечислению сырые значения, необходимо добавить тип сырых значений, отделенный от имени типа двоеточием, к объявлению типа. Затем мы присваиваем сырое значение каждому случаю, используя синтаксис присваивания. Вот пример перечисления для кодов состояния HTTP с типом сырых значений Int:
enum HTTPStatus: Int {
case ok = 200
case created = 201
// ...
case movedPermanently = 301
// ...
case notFound = 404
// ...
}
Каждый случай должен иметь уникальное сырое значение. Если мы не предоставим значения для одного или нескольких случаев, компилятор попытается выбрать разумные значения по умолчанию. В этом примере мы могли бы опустить явное присваивание сырого значения для случая created; компилятор выбрал бы то же значение, 201, увеличив сырое значение предыдущего случая.
Протокол RawRepresentable Link to heading
Тип, соответствующий протоколу RawRepresentable, получает два новых API: свойство rawValue и инициализатор, который может завершиться неудачей (init?(rawValue:)). Эти элементы объявлены в протоколе RawRepresentable (компилятор автоматически реализует этот протокол для перечислений с сырыми значениями):
/// Тип, который может быть преобразован в связанное сырое значение и обратно.
protocol RawRepresentable {
/// Тип сырых значений, например, Int или String.
associatedtype RawValue
init ?(rawValue: RawValue)
var rawValue: RawValue { get }
}
Инициализатор может завершиться неудачей, потому что не для каждого значения, соответствующего типу, может существовать допустимое значение типа RawValue. Например, только несколько десятков целых чисел являются допустимыми кодами состояния HTTP; для всех остальных входных данных HTTPStatus.init?(rawValue:) должен вернуть nil:
HTTPStatus(rawValue: 404) // Optional(HTTPStatus.notFound)
HTTPStatus(rawValue: 1000) // nil
HTTPStatus.created.rawValue // 201
Согласование с RawRepresentable вручную Link to heading
Вышеуказанный синтаксис для присвоения сырых значений перечислению работает только для ограниченного набора типов; тип сырых значений может быть String, Character или любым целым или вещественным типом. Это охватывает множество случаев использования, но это не означает, что эти типы являются единственными возможными сырыми типами значений. Поскольку вышеуказанный синтаксис является просто синтаксическим сахаром для согласования с RawRepresentable, у вас всегда есть возможность реализовать согласование вручную, если вам нужна большая гибкость.
Следующий пример определяет перечисление, которое представляет точки в логической системе координат, где координаты x и y могут принимать значения от -1 (лево/низ) до 1 (право/верх). Эта система координат несколько похожа на свойство anchorPoint класса CALayer в фреймворке Core Animation от Apple. Мы используем пару целых чисел в качестве нашего типа сырых значений, и поскольку удобный синтаксис не поддерживает кортежи, мы реализуем RawRepresentable вручную:
enum AnchorPoint {
case center
case topLeft
case topRight
case bottomLeft
case bottomRight
}
extension AnchorPoint: RawRepresentable {
typealias RawValue = (x: Int, y: Int)
var rawValue: (x: Int, y: Int) {
switch self {
case .center: return (0, 0)
case .topLeft: return (-1, 1)
case .topRight: return (1, 1)
case .bottomLeft: return (-1, -1)
case .bottomRight: return (1, -1)
}
}
init?(rawValue: (x: Int, y: Int)) {
switch rawValue {
case (0, 0): self = .center
case (-1, 1): self = .topLeft
case (1, 1): self = .topRight
case (-1, -1): self = .bottomLeft
case (1, -1): self = .bottomRight
default: return nil
}
}
}
Это немного больше кода для написания, но это не сложно. И это именно тот код, который компилятор генерирует для нас в своей автоматической синтезе RawRepresentable. Неудивительно, что поведение для пользователей перечисления одинаково в обоих случаях:
AnchorPoint.topLeft.rawValue // (x: -1, y: 1)
AnchorPoint(rawValue: (x: 0, y: 0)) // Optional(AnchorPoint.center)
AnchorPoint(rawValue: (x: 2, y: 1)) // nil
Одно, на что стоит обратить внимание при ручной реализации RawRepresentable, — это присвоение дублирующих сырых значений. Автоматическая синтез требует, чтобы сырые значения были уникальными — дубликаты вызывают ошибку компиляции. Но в ручной реализации компилятор не остановит вас от возврата одного и того же сырого значения для нескольких случаев перечисления.
Может быть веская причина использовать дублирующие сырые значения (например, когда ваше перечисление использует несколько случаев как синонимы друг друга, возможно, для обратной совместимости), но это должно быть исключением. Переключение по перечислению всегда сопоставляет с случаями перечисления и никогда с сырыми значениями. Другими словами, вы не можете сопоставить один случай с другим, даже если у них одинаковое сырое значение.
RawRepresentable для структур и классов Link to heading
Кстати, RawRepresentable не ограничивается перечислениями; вы также можете реализовать его для классов. Соответствие RawRepresentable часто является хорошим выбором для простых оберток, которые вводятся для сохранения типобезопасности. Например, программа может использовать строки для представления идентификаторов пользователей внутри. Вместо того чтобы использовать String напрямую, разумно определить новый тип UserID, чтобы предотвратить случайные смешивания с другими строковыми переменными. При этом должно быть возможно инициализировать UserID с помощью строки и извлекать его строковое значение; RawRepresentable хорошо подходит для этих требований:
struct UserID: RawRepresentable {
var rawValue: String
}
Здесь свойство rawValue удовлетворяет одному из двух требований протокола, но где же реализация второго требования — инициализатора? Она предоставляется автоматическим инициализатором с членами для структур в Swift. Компилятор достаточно умен, чтобы принять (непровальный) init(rawValue:) как реализацию для инициализатора, который требует протокол. Это имеет приятный побочный эффект: нам не нужно иметь дело с опциональными значениями при создании UserID из строки. Если бы мы хотели выполнить валидацию входной строки (возможно, не все строки являются действительными идентификаторами пользователей), нам пришлось бы предоставить собственную реализацию для init?(rawValue:).
Внутреннее представление сырых значений Link to heading
Помимо добавленных API RawRepresentable и различных правил для автоматической синтезации Codable, перечисления с сырыми значениями на самом деле не отличаются от всех других перечислений. В частности, перечисления с сырыми значениями сохраняют свою полную типовую идентичность. В отличие от C, где вы можете присваивать произвольные целочисленные значения переменным типа перечисления, перечисление Swift с сырыми значениями типа Int не «превращается» в целое число. Единственные возможные значения, которые может иметь экземпляр перечисления, — это случаи перечисления, и единственный способ получить сырые значения — это через API rawValue и init?(rawValue:).
Наличие сырых значений также не изменяет представление перечисления в памяти. Мы можем это проверить, определив перечисление с сырыми значениями типа String и посмотрев на размер типа:
enum MenuItem: String {
case undo = "Undo"
case cut = "Cut"
case copy = "Copy"
case paste = "Paste"
}
MemoryLayout<MenuItem>.size // 1
Тип MenuItem занимает всего один байт. Это говорит нам о том, что экземпляр MenuItem не хранит сырое значение внутри — если бы это было так, он должен был бы занимать как минимум 16 байт (размер String на 64-битных платформах). Сгенерированная компилятором реализация для rawValue работает как вычисляемое свойство, аналогично реализации для AnchorPoint, показанной выше.
Перечисление случаев перечисления (Enum Cases) Link to heading
В типах SumTypes и ProductTypes выше мы говорили о жителях типа: множестве всех возможных значений, которые может иметь экземпляр типа. Часто бывает полезно работать с этими значениями как с коллекцией, например, итерировать по ним или подсчитывать их. Протокол CaseIterable моделирует эту функциональность, добавляя статическое свойство (т.е. свойство, которое вызывается на типе, а не на экземпляре) с именем allCases:
/// Тип, который предоставляет коллекцию всех своих значений.
protocol CaseIterable {
associatedtype AllCases: Collection
where AllCases.Element == Self
**static var** allCases: AllCases { get }
}
Для перечислений без связанных значений компилятор может автоматически сгенерировать реализацию CaseIterable; все, что нам нужно сделать, это объявить соответствие. Давайте сделаем это для нашего типа MenuItem из предыдущего раздела:
enum MenuItem: String, CaseIterable {
case undo = "Undo"
case cut = "Cut"
case copy = "Copy"
case paste = "Paste"
}
Поскольку свойство allCases является коллекцией, оно имеет все обычные свойства и возможности, которые вы знаете из массивов и других коллекций. В следующем примере мы используем allCases, чтобы получить количество всех элементов меню и преобразовать их в строки, подходящие для отображения в пользовательском интерфейсе (для простоты мы используем сырые значения напрямую в качестве названий элементов меню; реальное приложение использовало бы сырые значения в качестве ключей в таблице поиска, где хранятся локализованные названия):
MenuItem.allCases
// [MenuItem.undo, MenuItem.cut, MenuItem.copy, MenuItem.paste]
MenuItem.allCases.count // 4
MenuItem.allCases.map { $0.rawValue } // ["Undo", "Cut", "Copy", "Paste"]
Подобно другим реализациям протоколов, сгенерированным компилятором, таким как Equatable и Hashable, наибольшим преимуществом автогенерируемого соответствия CaseIterable является не сложность самого кода (это тривиально — написать ручную реализацию), а тот факт, что сгенерированный компилятором код всегда будет актуальным — ручные соответствия необходимо обновлять каждый раз, когда случаи добавляются или удаляются, что очень легко забыть сделать.
Протокол CaseIterable не предписывает конкретный порядок значений в коллекции allCases, но документация для CaseIterable гарантирует, что синтезированное соответствие предоставляет случаи в порядке их объявления.
Ручная реализация CaseIterable Link to heading
CaseIterable особенно полезен для простых перечислений без связанных значений, и именно эти типы охватываются автоматической компиляцией. Это имеет смысл, поскольку добавление связанных значений к перечислению делает количество возможных значений перечисления потенциально бесконечным. Однако, пока мы можем придумать способ создать коллекцию всех значений, мы всегда можем реализовать соответствие вручную. На самом деле, мы даже не ограничены перечислениями. Хотя названия CaseIterable и allCases подразумевают, что эта функция предназначена в основном для перечислений (другие типы не имеют случаев), компилятор с радостью примет структуру или класс, соответствующий протоколу.
Одним из самых простых типов для написания ручной реализации CaseIterable является Bool:
extension Bool: CaseIterable {
public static var allCases: [Bool] {
return [false, true]
}
}
Bool.allCases // [false, true]
Некоторые целочисленные типы также хорошо подходят. Обратите внимание, что возвращаемый тип allCases не обязательно должен быть массивом — это может быть любая коллекция. Было бы нецелесообразно генерировать массив каждого возможного целого числа, когда диапазон может представлять ту же коллекцию с гораздо меньшими затратами памяти:
extension UInt8: CaseIterable {
public static var allCases: ClosedRange<UInt8> {
return .min ... .max
}
}
UInt8.allCases.count // 256
UInt8.allCases.prefix(3) + UInt8.allCases.suffix(3) // [0, 1, 2, 253, 254, 255]
По той же логике, если вы хотите написать реализацию CaseIterable для типа с большим количеством значений или типа, где значения дорого генерировать, рассмотрите возможность возврата ленивой коллекции, чтобы не выполнять ненужную работу заранее. Мы обсудим ленивые коллекции в главе о протоколах коллекций.
Обратите внимание, что оба этих примера игнорируют общее правило не соответствовать типам, которые вы не владеете, протоколам, которыми вы не владеете. Прежде чем нарушать это правило в производственном коде, подумайте о компромиссах, связанных с этим. Обратитесь к главе о протоколах для получения дополнительной информации.
Замороженные и Незапрещенные Перечисления Link to heading
Мы неоднократно подчеркивали в этой главе, что одной из лучших качеств перечислений (enums) является возможность исчерпывающего переключения по ним. Компилятор может выполнять свои проверки на исчерпываемость только в том случае, если он знает на этапе компиляции обо всех возможных случаях, которые может иметь перечисление. Это тривиально верно для объявлений перечислений, которые находятся в том же модуле, что и код, который по ним переключается. Это также верно, если объявление перечисления находится в другой библиотеке, но эта библиотека компилируется вместе с клиентским кодом — каждый раз, когда случай добавляется или удаляется, объявление перечисления и клиентский код перекомпилируются, что позволяет компилятору повторно проверять все операторы switch.
Однако существуют ситуации, когда мы хотим переключаться по перечислению из модуля, который подключен к нашей программе в бинарной форме. Стандартная библиотека является самым ярким примером этого: даже если исходный код стандартной библиотеки свободно доступен, мы обычно используем бинарную версию, которая поставляется с нашим дистрибутивом Swift или операционной системой. То же самое касается других библиотек, которые поставляются с Swift, включая Foundation и Dispatch. Наконец, Apple и другие компании хотят поставлять библиотеки Swift в бинарной форме.
Чтобы привести пример типа стандартной библиотеки, предположим, что мы хотим переключаться по экземпляру DecodingError в нашем коде. DecodingError — это перечисление, которое на момент Swift 5.5 имеет четыре случая для указания различных условий ошибок:
let error: DecodingError = ...
// Исчерпывающее на этапе компиляции, но возможно не на этапе выполнения.
switch error {
case .typeMismatch: ...
case .valueNotFound: ...
case .keyNotFound: ...
case .dataCorrupted: ...
}
Не исключено, что в будущих версиях Swift будут добавлены дополнительные случаи по мере расширения системы Codable. Но если мы создадим приложение, которое содержит этот код, и отправим его клиентам, некоторые из этих клиентов могут в конечном итоге запустить исполняемый файл на более новой ОС с более новой версией Swift, которая включает другой случай DecodingError. В этой ситуации наша программа аварийно завершится, потому что она столкнется с условием, которое не сможет обработать.
Перечисление, которое может получить новые случаи в будущем, называется незапрещенным (non-frozen). Чтобы сделать программы устойчивыми к изменениям незапрещенных перечислений, код, который переключается по незапрещенному перечислению из другого модуля, всегда должен включать оператор default для обработки будущих случаев. В Swift 5.5 компилятор выдает только предупреждение (а не ошибку), если вы пропускаете случай по умолчанию, но это лишь временная ситуация, чтобы облегчить миграцию существующего кода. В будущих релизах это станет ошибкой.
Если вы примете исправление компилятора для предупреждения, вы заметите, что оно добавляет атрибут @unknown к случаю по умолчанию:
switch error {
// ...
case .dataCorrupted: ...
@unknown default:
// Обработка неизвестных случаев.
// ...
}
@unknown default ведет себя как обычный случай по умолчанию во время выполнения, но это также сигнал для компилятора о том, что случай по умолчанию предназначен только для обработки случаев перечисления, которые неизвестны на этапе компиляции. Если случай по умолчанию совпадает с известным на этапе компиляции, мы все равно получим предупреждение. Это означает, что мы все еще можем извлечь выгоду из проверки на исчерпываемость, когда мы перекомпилируем нашу программу против нового интерфейса библиотеки в будущем. Если случай был добавлен в API библиотеки с момента последнего обновления, мы получим предупреждения о необходимости обновить все наши операторы switch, чтобы явно обработать новый случай. @unknown default дает вам лучшее из обоих миров: проверку на исчерпываемость на этапе компиляции и безопасность во время выполнения.
Различие между замороженными и незапрещенными перечислениями включается только для модулей, которые компилируются в режиме эволюции библиотеки (library evolution mode), который по умолчанию отключен и активируется с помощью флага компилятора -enable-library-evolution. Модули, у которых включена эволюция библиотеки, также называются устойчивыми библиотеками (resilient libraries); они разработаны так, чтобы поддерживать стабильный ABI, позволяя при этом авторам библиотеки вносить определенные изменения в API. Добавление случая перечисления — это один из примеров такого изменения. Стандартная библиотека является устойчивой библиотекой, как и все фреймворки в SDK Apple. Перечисления в устойчивых библиотеках — то есть библиотеках, которые разрабатываются между релизами — по умолчанию являются незапрещенными. Существует также атрибут @frozen для маркировки конкретного объявления перечисления как замороженного. Используя этот атрибут, разработчики библиотеки обещают никогда не добавлять другой случай к этому перечислению — это нарушило бы бинарную совместимость.
Примеры замороженных перечислений в стандартной библиотеке включают Optional и Result; если бы они не были заморожены, переключение по ним всегда требовало бы оператора default, что было бы серьезным неудобством.
Перечисления в неустойчивых библиотеках (что охватывает все модули, которые вы компилируете и связываете непосредственно в вашу программу, такие как зависимости Swift Package Manager, которые являются открытым исходным кодом) всегда считаются замороженными и, следовательно, не требуют случая @unknown default. Это нормально, потому что бинарные интерфейсы этих модулей никогда не выйдут из синхронизации.
Советы и рекомендации Link to heading
Мы завершим главу несколькими советами и рекомендациями.
Старайтесь избегать вложенных операторов switch. Вы можете использовать кортеж для переключения между несколькими значениями сразу. Например, предположим, что вы хотите установить переменную в зависимости от значений двух булевых переменных. Переключение между булевыми переменными одна за другой требует повторения внутреннего оператора switch, и это быстро становится неаккуратным:
let isImportant: Bool = ...
let isUrgent: Bool = ...
let priority: Int
switch isImportant {
case true:
switch isUrgent {
case true: priority = 3
case false: priority = 2
}
case false:
switch isUrgent {
case true: priority = 1
case false: priority = 0
}
}
Помещение двух булевых переменных в кортеж и переключение по нему короче и более читаемо:
let priority2: Int
switch (isImportant, isUrgent) {
case (true, true): priority2 = 3
case (true, false): priority2 = 2
case (false, true): priority2 = 1
case (false, false): priority2 = 0
}
Воспользуйтесь проверками на определенную инициализацию. Взгляните еще раз на предыдущий пример кода. Этот шаблон объявления, но не инициализации, константы let перед оператором switch, а затем инициализации ее в каждом случае оператора switch, использует преимущества проверок на определенную инициализацию компилятора. Компилятор проверяет, что переменная была полностью инициализирована перед ее первым использованием — он выдаст ошибку, если мы забудем инициализировать в одном или нескольких путях кода. Этот стиль гораздо безопаснее, чем наивная альтернатива, когда priority является var и присваивается дважды (один раз на месте объявления и затем снова внутри switch).
Как и if, switch является оператором, а не выражением — хотя мы часто желаем, чтобы это было последним. В Swift нет удобного синтаксиса для установки переменной в результате переключения по enum. Объявление константы перед оператором switch и присвоение ей в каждом случае — это лучшее, что мы можем сделать.
Избегайте именования случаев enum как none или some. Это привлекательные имена для случая enum; мы рекомендуем вам избегать их из-за потенциального конфликта с именами случаев Optional в контекстах сопоставления с образцом. Итак, это проблемное определение enum:
enum Selection {
case none
case some
case all
}
Предположим, у нас есть переменная типа Selection? (т.е. опциональная) и мы хотим сопоставить ее с образцом:
var optionalSelection: Selection? = ...
if case .some = optionalSelection {
// Некоторые элементы выбраны? Или?
}
Сопоставляет ли это только Selection.some или оно также сопоставляет Optional.some, т.е. любое ненулевое значение? Ответ — последнее, но это легко перепутать, особенно учитывая, что Swift любит неявно преобразовывать ненулевые значения в опционалы. (Компилятор выдает предупреждение для if case.none = optionalSelection, чтобы указать на неоднозначность, но не для if case.some = ….)
Используйте обратные кавычки для имен случаев, которые являются зарезервированными словами. Если вы используете определенные ключевые слова в качестве имен случаев (например, default), проверка типов будет жаловаться, потому что не может разобрать код. Вы можете обернуть слово в обратные кавычки, чтобы использовать его в любом случае:
enum Strategy {
case custom
case `default` // требует обратных кавычек.
}
Что приятно в этом, так это то, что обратные кавычки не требуются в местах, где проверка типов может устранить неоднозначность. Это совершенно допустимо:
let strategy = Strategy.default
Случаи enum могут использоваться как фабричные методы. Если у случая enum есть связанное значение, имя случая само по себе формирует функцию с сигнатурой (AssocValue) -> Enum. Возьмем этот enum для представления цвета в одной из двух цветовых пространств — RGB или градации серого:
enum OpaqueColor {
case rgb(red: Float, green: Float, blue: Float)
case gray(intensity: Float)
}
OpaqueColor.rgb — это функция, которая принимает три Float и производит OpaqueColor:
OpaqueColor.rgb // (Float, Float, Float) -> OpaqueColor
Мы также можем передавать эти функции высшего порядка, такие как map. Здесь мы создаем градиент серых цветов от черного к белому, передавая случай enum напрямую в map как фабричный метод:
let gradient = stride(from: 0.0, through: 1.0, by: 0.25).map(OpaqueColor.gray)
/*
[OpaqueColor.gray(intensity: 0.0), OpaqueColor.gray(intensity: 0.25),
OpaqueColor.gray(intensity: 0.5), OpaqueColor.gray(intensity: 0.75),
OpaqueColor.gray(intensity: 1.0)]
*/
Случаи enum могут даже удовлетворять требованиям протоколов, которые имеют ту же форму. Здесь требование статического метода протокола напрямую соответствует случаю enum с тем же именем, так что соответствие получается бесплатно:
protocol ColorProtocol {
static func rgb(red: Float, green: Float, blue: Float) -> Self
}
// Код не требуется.
extension OpaqueColor: ColorProtocol {}
Не используйте связанные значения для имитации хранимых свойств. Используйте структуру вместо этого. Enum не могут иметь хранимые свойства. Это может показаться значительным ограничением, но это не так. Если вы обдумаете это, добавление хранимого свойства типа T на самом деле не отличается от добавления связанного значения того же типа к каждому случаю. Например, давайте добавим альфа-канал к нашему типу OpaqueColor из выше, дав каждому случаю еще одно связанное значение:
enum AlphaColor {
case rgba(red: Float, green: Float, blue: Float, alpha: Float)
case gray(intensity: Float, alpha: Float)
}
Это работает, но извлечение значения альфа из экземпляра AlphaColor теперь не очень удобно — нам придется переключаться по экземпляру и извлекать значение из каждого случая, даже если мы знаем, что каждый AlphaColor имеет компонент альфа. Мы могли бы обернуть эту логику в вычисляемое свойство, но лучшее решение может заключаться в том, чтобы избежать проблемы с самого начала — давайте обернем оригинальный enum OpaqueColor в структуру и сделаем alpha хранимым свойством структуры:
struct Color {
var color: OpaqueColor
var alpha: Float
}
Это общий шаблон: когда вы видите enum, где каждый случай имеет один и тот же кусок данных в своем полезном нагрузке, рассмотрите возможность обернуть enum в структуру и вынести общее свойство. Это изменяет форму результирующего типа, но не меняет его фундаментальную природу. Это то же самое, что факторизовать общий множитель в математическом уравнении: a×b + a×c = a×(b + c). Это соответствие с алгеброй является причиной, по которой общее название для типов суммы и произведения — «алгебраический тип данных».
Не переусердствуйте с компонентами связанных значений. Мы использовали связанные значения с несколькими компонентами в стиле кортежа — такими как OpaqueColor.rgb(red:green:blue:) — довольно активно в этой главе. Это удобно для коротких примеров, но в производственном коде написание пользовательской структуры для каждого случая часто является лучшим выбором. Сравните две версии типа Shape, которые мы использовали выше в разделе Сопоставление с образцом. Сначала вот оригинальный стиль кортежа:
enum Shape {
case line(from: Point, to: Point)
case rectangle(origin: Point, width: Double, height: Double)
case circle(center: Point, radius: Double)
}
А вот альтернатива с одной пользовательской структурой на случай:
struct Line {
var from: Point
var to: Point
}
struct Rectangle {
var origin: Point
var width: Double
var height: Double
}
struct Circle {
var center: Point
var radius: Double
}
enum Shape2 {
case line(Line)
case rectangle(Rectangle)
case circle(Circle)
}
Последний пример требует написания немного большего количества кода изначально, но он упрощает объявление enum, а также шаблоны в операторах switch. Кроме того, у структур есть собственная идентичность; мы можем расширять их и соответствовать им протоколам.
Используйте enum без случаев в качестве пространств имен. Помимо неявных пространств имен, образованных модулями, в Swift нет встроенных пространств имен. Однако мы можем использовать enum в качестве «фальшивых» пространств имен. Поскольку определения типов могут быть вложенными, внешние типы действуют как пространства имен для всех объявлений, которые они содержат. Как мы видели в главе о Опционалах, enum, у которых нет случаев — такие как Never — не могут быть инстанцированы. Это делает пустой enum без случаев лучшим вариантом для определения пользовательского пространства имен. Стандартная библиотека делает это тоже — например, с «пространством имен» Unicode:
/// Пространство имен для утилит Unicode.
public enum Unicode {
public struct Scalar {
internal var _value: UInt32
// ...
}
// ...
}
К сожалению, enum без случаев не является идеальным обходным путем для отсутствия надлежащих пространств имен: протоколы не могут быть вложены внутри других объявлений, и именно поэтому связанный стандартный библиотечный протокол называется UnicodeCodec, а не Unicode.Codec.
Резюме Link to heading
Enumsaresumtypes. При определении пользовательских типов перечисления являются важным инструментом для избежания комбинаторного взрыва нежелательных состояний, характерного для дизайна, основанного исключительно на типах продуктов. Тщательное обдумывание обитателей типа помогает нам принимать лучшие проектные решения. Перечисление, или комбинация вложенных перечислений и структур, часто является наилучшим выбором, если вам нужен тип, который точно соответствует решаемой вами задаче — например, для моделирования состояния вашей программы.
Перечисления лучше подходят для различных шаблонов проектирования, чем более знакомые типы записей. Ваша цель должна заключаться в том, чтобы сделать невозможным представление незаконных состояний программы в ваших типах. Это сокращает множество состояний, с которыми ваш код должен быть готов справляться, и позволяет компилятору направлять вас при написании нового кода. Когда это возможно, используйте проверки исчерпываемости компилятора.
Строки Link to heading
8 Link to heading
Все современные языки программирования поддерживают строки в формате Unicode, но это часто означает лишь то, что нативный тип строки может хранить данные Unicode — это не обещание, что простые операции, такие как получение длины строки, вернут «разумные» результаты. На самом деле, большинство языков, а следовательно, и большинство кода для манипуляции строками, написанного на этих языках, демонстрируют определенный уровень отрицания о внутренней сложности Unicode. Это может привести к неприятным ошибкам.
Реализация строк в Swift прилагает героические усилия, чтобы быть как можно более корректной в отношении Unicode. Строка в Swift — это коллекция значений Character, где Character — это то, что читатель текста воспринимает как один символ, независимо от того, из скольких Unicode-скаляров она состоит. В результате все стандартные операции коллекции — такие как count или prefix(5) — работают на уровне воспринимаемых пользователем символов.
Это отлично для корректности, но имеет свою цену, в основном в терминах непривычности; если вы привыкли манипулировать строками с помощью целочисленных индексов в других языках, дизайн Swift может показаться вам громоздким на первый взгляд, оставляя вас в недоумении: Почему я не могу написать str[999], чтобы получить тысячный символ строки? Почему str[idx+1] не возвращает следующий символ? Почему я не могу перебрать диапазон значений Character, таких как “a”…“z”? Это также имеет последствия для производительности: String не поддерживает произвольный доступ, т.е. переход к произвольному символу не является операцией O(1). Это не может быть так — когда символы имеют переменную ширину, строка не знает, где хранится n-й символ, не просматривая все символы, которые идут перед ним.
В этой главе мы подробно обсудим архитектуру строк, а также некоторые техники для максимального использования строк Swift с точки зрения функциональности и производительности. Но начнем с обзора необходимой терминологии Unicode.
Юникод Link to heading
Раньше все было так просто. Строки ASCII представляли собой последовательность целых чисел от 0 до 127. Если вы хранили их в 8-битном байте, у вас даже оставался один бит в запасе! Поскольку каждый символ имел фиксированный размер, строки ASCII могли быть доступны в произвольном порядке. Но ASCII было недостаточно, если вы писали на чем-то, кроме английского, или для аудитории вне США; другим странам и языкам нужны были другие символы (даже англоговорящей Британии нужен был знак фунта £). Большинство из них требовало больше символов, чем можно было уместить в семь бит. ISO 8859 берет дополнительный бит и определяет 16 различных кодировок выше диапазона ASCII, таких как Часть 1 (ISO 8859-1, также известная как Latin-1), охватывающая несколько западноевропейских языков; и Часть 5, охватывающая языки, использующие кириллицу. Однако это все еще ограничивает: если вы хотите использовать ISO 8859 для написания на турецком о Древней Греции, вам не повезло, так как вам придется выбрать либо Часть 7 (латиница/греческий), либо Часть 9 (турецкий). А восемь бит все еще недостаточно для кодирования многих языков. Например, Часть 6 (латиница/арабский) не включает символы, необходимые для написания языков, использующих арабский алфавит, таких как урду или персидский. Тем временем вьетнамский — который основан на латинском алфавите, но с большим количеством диакритических комбинаций — помещается в восемь бит, заменяя несколько символов ASCII из нижней половины. И это даже не вариант для других восточноазиатских языков.
Когда у вас заканчивается место с фиксированной шириной кодирования, у вас есть выбор: либо увеличить размер, либо переключиться на кодирование переменной ширины. Изначально Юникод был определен как 2-байтовый формат фиксированной ширины, который теперь называется UCS-2. Это было до того, как реальность вступила в силу, и было принято, что даже двух байтов (т.е. 65,000 кодовых точек) будет недостаточно, в то время как четыре будут ужасно неэффективны для большинства целей. Поэтому сегодня Юникод является форматом переменной ширины, и он переменный в двух разных смыслах: → Один символ (также известный как расширенный кластер графем) состоит из одного или нескольких скалярных значений Юникода. → Скалярное значение кодируется одной или несколькими единицами кода.
Чтобы понять, почему, нам нужно уточнить, что означают эти термины. Основным строительным блоком Юникода является кодовая точка: целочисленное значение в кодовом пространстве Юникода, которое варьируется от 0 до 0x10FFFF (в десятичной нотации: 1,114,111). Каждому символу или другой единице письма, которая является частью Юникода, присваивается уникальная кодовая точка. В Юникоде 14 (опубликованном в сентябре 2021 года) только около 145,000 из 1.1 миллиона доступных кодовых точек в настоящее время используются, так что есть много места для новых эмодзи. Кодовые точки обычно записываются в шестнадцатеричной нотации с префиксом “U+”. Например, знак евро — это кодовая точка U+20AC (или 8364 в десятичной системе).
Скалярные значения Юникода почти, но не совсем, такие же, как кодовые точки. Это все кодовые точки, кроме 2048 суррогатных кодовых точек в диапазоне 0xD800 до 0xDFFF (которые используются кодировкой UTF-16 для представления кодовых точек, превышающих 65,535). Скалярные значения представлены в строковых литералах Swift как “\u{xxxx}”, где xxxx представляет собой шестнадцатеричные цифры. Таким образом, знак евро можно записать в Swift как “€” или “\u{20AC}”. Соответствующий тип Swift — это Unicode.Scalar, который является оберткой вокруг значения UInt32.
Одни и те же данные Юникода (т.е. последовательность скалярных значений) могут быть закодированы с помощью различных кодировок, при этом UTF-8 и UTF-16 являются наиболее распространенными. Наименьшая сущность в кодировке называется единицей кода. Кодировка UTF-8 имеет 8-битные единицы кода, а UTF-16 — 16-битные единицы кода. UTF-8 имеет дополнительное преимущество обратной совместимости с 8-битным ASCII — функция, которая помогла ей обойти ASCII как наиболее популярную кодировку в Интернете и в файловых форматах. Единицы кода отличаются от кодовых точек или скалярных значений, потому что одно скалярное значение часто кодируется с помощью нескольких единиц кода. Поскольку существует более миллиона потенциальных кодовых точек, UTF-8 требует от одной до четырех единиц кода (от одного до четырех байтов) для кодирования одного скалярного значения, в то время как UTF-16 требует либо одной, либо двух единиц кода (два или четыре байта). Swift представляет единицы кода UTF-8 и UTF-16 как значения UInt8 и UInt16 соответственно (псевдонимы как Unicode.UTF8.CodeUnit и Unicode.UTF16.CodeUnit).
Чтобы представить каждое скалярное значение одной единицей кода, вам потребуется схема кодирования на 21 бит, которая обычно округляется до 32 бит и называется UTF-32. Это то, что делает Unicode.Scalar в Swift. Но даже это не даст вам кодировку фиксированной ширины: Юникод все еще является форматом переменной ширины, когда дело доходит до “символов”. То, что пользователь может считать “одним символом” — как это отображается на экране — может требовать нескольких скалярных значений, собранных вместе. Термин Юникода для такого воспринимаемого пользователем символа — это (расширенный) кластер графем.
Правила того, как скалярные значения формируют кластеры графем, определяют, как текст сегментируется. Например, если вы нажмете клавишу Backspace на клавиатуре, вы ожидаете, что ваш текстовый редактор удалит ровно один кластер графем, даже если этот “символ” состоит из нескольких скалярных значений Юникода, каждое из которых может использовать различное количество единиц кода в представлении текста в памяти. Кластеры графем представлены в Swift типом Character, который может кодировать произвольное количество скалярных значений, при условии, что они формируют один воспринимаемый пользователем символ.
Группы графем и канонические Link to heading
Эквивалентность Link to heading
Сочетаемые знаки Link to heading
Быстрый способ увидеть, как строка обрабатывает данные Unicode, — это посмотреть на два разных способа написания é. Unicode определяет U+00E9, латинскую строчную букву e с акцентом, как одно значение. Но вы также можете записать это как обычную букву e, за которой следует U+0301, комбинирующий острый акцент. В обоих случаях отображается é, и пользователи ожидают, что две строки, отображаемые как “résumé”, будут не только равны друг другу, но и иметь “длину” шесть символов, независимо от того, какая техника использовалась для получения é в любом из случаев. Они будут тем, что спецификация Unicode описывает как канонически эквивалентные.
И в Swift это именно то поведение, которое вы получаете:
let single = "Pok\u{00E9}mon" // Pokémon
let double = "Poke\u{0301}mon" // Pok é mon
Они оба отображаются идентично:
(single, double) // ("Pokémon", "Pok é mon")
И оба имеют одинаковое количество символов:
single.count // 7
double.count // 7
Следовательно, они также сравниваются как равные:
single == double // true
Только если вы опуститесь до представления под капотом, вы сможете увидеть, что они разные:
single.unicodeScalars.count // 7
double.unicodeScalars.count // 8
Сравните это с NSString в Foundation: две строки не равны, и свойство length — которое многие программисты на Objective-C, вероятно, используют для подсчета количества символов, которые будут отображаться на экране — дает разные результаты:
let nssingle = single as NSString
nssingle.length // 7
let nsdouble = double as NSString
nsdouble.length // 8
nssingle == nsdouble // false
Здесь == определяется как версия для сравнения двух NSObject:
extension NSObject: Equatable {
static func ==(lhs: NSObject, rhs: NSObject) -> Bool {
return lhs.isEqual(rhs)
}
}
В случае NSString == будет выполнять буквальное сравнение на уровне кодовых единиц UTF-16, а не учитывать эквивалентные, но составленные по-разному символы. Большинство API строк в других языках работают так же. Если вы действительно хотите выполнить каноническое сравнение двух NSString, вы должны использовать NSString.compare(_:).
Конечно, есть одно большое преимущество просто сравнения кодовых единиц: это быстрее! Этот эффект все еще можно достичь с помощью строк Swift через представление utf8:
single.utf8.elementsEqual(double.utf8) // false
Почему Unicode поддерживает несколько представлений одного и того же символа? Существование предварительно составленных символов позволяет диапазону кодовых точек Unicode быть совместимым с Latin-1, который уже имел такие символы, как é и ñ. Хотя с ними может быть сложно работать, это делает конвертацию между двумя кодировками быстрой и простой.
И отказ от предварительно составленных форм все равно не помог бы, потому что составление не останавливается только на парах; вы можете составить более одного диакритического знака вместе. Например, в языке йоруба есть символ ọ́, который можно записать тремя разными способами: составив ó с точкой, или составив ọ с острым акцентом, или составив o с обоими — острым акцентом и точкой. И для последнего варианта два диакритических знака могут быть в любом порядке! Таким образом, все они равны:
let chars: [Character] = [
"\u{1ECD}\u{300}", // ọ́
"\u{F2}\u{323}", // ọ́
"\u{6F}\u{323}\u{300}", // ọ́
"\u{6F}\u{300}\u{323}" // ọ́
]
let allEqual = chars.dropFirst().allSatisfy { $0 == chars.first } // true
Правила разбиения графем Unicode даже влияют на вас, когда все строки, с которыми вы работаете, являются чистым ASCII: CR+LF, пара символов возврата каретки и перевода строки, которые обычно используются как разрыв строки в Windows, является одной графемой: // CR+LF — это один символ.
let crlf = "\r\n"
crlf.count // 1
Эмодзи Link to heading
😂
Строки, содержащие эмодзи, также могут быть удивительными в различных других языках программирования. Многие эмодзи имеют назначенные им юникодные скаляры, которые не помещаются в единицу кода UTF-16.
😂
Языки, которые представляют строки как коллекции единиц кода UTF-16, такие как Java или C#, будут считать, что строка “😅” длинной в два “символа”. Swift обрабатывает этот случай правильно:
let oneEmoji = "😅" // U+1F602
oneEmoji.count // 1
Обратите внимание, что важным является то, как строка представляется в программе, а не то, как она хранится в памяти. Swift использует UTF-8 в качестве внутреннего кодирования, но это деталь реализации. Публичный API основан на кластерах графем.
🇧🇷 🇳🇿
Другие эмодзи состоят из нескольких скаляров. Эмодзи-флаг — это комбинация двух символов региональных индикаторов, которые соответствуют коду страны ISO. Swift правильно обрабатывает флаг как один символ:
let flags = "🇧🇷🇳🇿"
flags.count // 2
Чтобы проверить, из каких юникодных скаляров состоит строка, используйте представление unicodeScalars. Здесь мы форматируем значения скаляров как шестнадцатеричные числа в общем формате для кодовых точек:
flags.unicodeScalars.map {
"U+\(String($0.value, radix: 16, uppercase: true))"
}
// ["U+1F1E7", "U+1F1F7"]
👧🏽
👧
👧
👧🏽
Тоны кожи комбинируют базовый символ, такой как 👧, с одним из пяти модификаторов тона кожи (например, 🏽, или модификатор тона кожи типа 4), чтобы получить финальный эмодзи (👧🏽). Снова Swift обрабатывает это правильно:
let skinTone = "👧🏽" // 👧 + 🏽
skinTone.count // 1
Эмодзи, изображающие семьи и пары, такие как 👪 и 👨👩👧👦, представляют собой другую проблему для стандарта юникода. Из-за бесчисленных возможных комбинаций пола и количества людей в группе предоставление отдельной кодовой точки для каждой вариации является проблематичным. Сочетайте это с различным тоном кожи для каждого человека, и это становится невозможным. Юникод решает эту проблему, указывая, что эти эмодзи на самом деле являются последовательностями нескольких эмодзи, объединенных невидимым символом нулевой ширины (ZWJ) (U+200D). Таким образом, семья 👪 на самом деле представляет собой man + ZWJ + woman + ZWJ + girl + ZWJ + boy. ZWJ служит индикатором для операционной системы, что она должна использовать один глиф, если он доступен.
Вы можете проверить, что это действительно так:
let family1 = "👪"
let family2 = "👨👩👧👦"
family1 == family2 // true
И снова Swift достаточно умен, чтобы рассматривать такую последовательность как один символ:
family1.count // 1
family2.count // 1
Эмодзи для профессий также являются последовательностями ZWJ. Например, женщина-пожарный — это woman + ZWJ + fireengine, а мужчина-медработник — это man + ZWJ + staff of Aesculapius ⚕. Отрисовка этих последовательностей в один глиф — задача операционной системы. На платформах Apple в 2022 году ОС включает глифы для подмножества последовательностей, которые стандарт юникода перечисляет как “рекомендуемые для общего обмена” (RGI), т.е. те, которые “наиболее вероятно будут широко поддерживаться на нескольких платформах”. Когда для синтаксически корректной последовательности нет доступного глифа, система рендеринга текста возвращается к отрисовке каждого компонента как отдельного глифа. Обратите внимание, что это может вызвать несоответствие “в другую сторону” между воспринимаемыми пользователем символами и тем, что Swift видит как кластер графем; все примеры до сих пор касались языков программирования, которые переоценивают символы, но здесь мы видим обратное. Например, последовательности семей с тонами кожи в настоящее время не являются частью подмножества RGI. Но даже если операционная система отрисовывает такую последовательность как несколько глифов, Swift все равно считает это одним символом, потому что правила сегментации текста юникода не касаются рендеринга:
// Семья с тонами кожи отрисовывается как несколько глифов
// на большинстве платформ в 2022 году.
let family3 = "👨👩👧👦"
// Но Swift все равно считает это одним символом.
family3.count // 1
Неважно, насколько тщательно API строк спроектирован, текст настолько сложен, что он может никогда не охватить все крайние случаи. Swift использует алгоритм разбиения графем операционной системы ICU. В результате ваши программы автоматически примут новые правила юникода, когда пользователи обновят свои ОС. Это означает, что вы не можете полагаться на то, что пользователи увидят то же поведение, что и вы во время разработки. Например, когда вы развертываете серверный код Swift на Linux, код может вести себя иначе, потому что дистрибутив Linux может поставляться с другой версией ICU, чем ваша машина разработки.
В примерах, которые мы обсуждали в этом разделе, мы рассматривали длину строки как прокси для всех видов вещей, которые могут пойти не так, когда язык не учитывает всю сложность юникода. Просто подумайте о бессмыслице, которую простая задача, такая как реверсирование строки, содержащей составные последовательности символов, может произвести в языке программирования, который не обрабатывает строки по кластерам графем. Это не новая проблема, но взрыв эмодзи сделал гораздо более вероятным, что ошибки, вызванные небрежной обработкой текста, выйдут на поверхность, даже если у вас преимущественно англоязычная база пользователей. И величина ошибок также возросла: если десять лет назад неправильно обработанный акцентированный символ вызывал ошибку “на один больше”, то сбой современного эмодзи может легко привести к тому, что результаты будут отличаться на 10 или более “символов”. Например, эмодзи семьи из четырех человек имеет длину 11 (UTF-16) или 25 (UTF-8) единиц кода:
family1.count // 1
family1.utf16.count // 11
family1.utf8.count // 25
Дело не в том, что в других языках вообще нет API, корректных по юникоду — большинство из них есть. Например, NSString имеет метод enumerateSubstrings, который можно использовать для обхода строки по кластерам графем. Но значения по умолчанию имеют значение, и приоритет Swift — делать правильные вещи по умолчанию. И если вам когда-либо нужно будет перейти на более низкий уровень абстракции, String предоставляет представления, которые позволяют вам работать непосредственно с юникодными скалярами или единицами кода. Мы скажем больше об этом ниже.
Строки и Коллекции Link to heading
Как мы уже видели, String является коллекцией значений символов. В первые три года существования Swift, String колебался между соответствием и несоответствием протоколу Collection. Аргументом против добавления соответствия было то, что программисты ожидали бы, что все обобщенные алгоритмы обработки коллекций будут полностью безопасными и корректными с точки зрения Unicode, что не всегда будет верно для всех крайних случаев.
В качестве простого примера, вы можете предположить, что если вы конкатенируете две коллекции, длина результирующей коллекции будет суммой длин двух исходных коллекций. Но это не так для строк, если суффикс первой строки образует графемный кластер с префиксом второй строки:
let flagLetterJ = " 🇯 "
let flagLetterP = " 🇵 "
let flag = flagLetterJ + flagLetterP //
flag.count // 1
flag.count == flagLetterJ.count + flagLetterP.count // false
С этой целью, String не был сделан коллекцией в Swift 2 и 3; вместо этого представление коллекции символов было перемещено в свойство characters, что поставило его на footing, аналогичный другим представлениям коллекций: unicodeScalars, utf8 и utf16. Выбор конкретного представления побуждал вас признать, что вы переходите в режим обработки коллекций и что вам следует учитывать последствия алгоритма, который вы собирались запустить.
На практике, выгода в корректности для нескольких крайних случаев, которые редко имеют значение в реальном коде (если вы не пишете текстовый редактор), оказалась не стоящей потерь в удобстве использования и обучаемости, вызванных этим изменением. Поэтому String снова стал коллекцией в Swift 4.
Двунаправленный, не случайный доступ Link to heading
Однако, по причинам, которые должны быть ясны из примеров, которые мы видели до сих пор в этой главе, String не является коллекцией с произвольным доступом. Как это может быть, если для определения местоположения n-го символа в конкретной строке необходимо оценить, сколько именно Unicode-скаляров предшествует этому символу? По этой причине String соответствует только BidirectionalCollection. Вы можете начинать с любого конца строки, перемещаясь вперед или назад, и код будет учитывать состав соседних символов и пропускать правильное количество байтов. Однако вам нужно итерировать по одному символу за раз.
Имейте в виду последствия для производительности при написании кода для обработки строк. Алгоритмы, которые зависят от случайного доступа для поддержания своих гарантий производительности, не подходят для строк Unicode. Рассмотрим это расширение String для генерации списка префиксов строки, которое работает, создавая диапазон целых чисел от нуля до длины строки, а затем отображая этот диапазон для создания префикса для каждой длины:
extension String {
var allPrefixes1: [Substring] {
return (0...count).map(prefix)
}
}
let hello = "Hello"
hello.allPrefixes1 // ["", "H", "He", "Hel", "Hell", "Hello"]
Несмотря на то, что этот код выглядит просто, он очень неэффективен. Сначала он проходит по строке один раз, чтобы вычислить длину, что нормально. Но затем каждый из n+1 вызовов prefix — это еще одна операция O(n), потому что prefix всегда начинается с начала и должен пройти через строку, чтобы подсчитать нужное количество символов. Запуск линейного процесса внутри другого линейного цикла означает, что этот алгоритм случайно имеет сложность O(n²) — по мере увеличения длины строки время, необходимое этому алгоритму, увеличивается квадратично.
Если возможно, эффективный алгоритм для строк должен проходить по строке только один раз, а затем работать с индексами строки, чтобы отметить подстроки, которые его интересуют. Вот лучшая версия того же алгоритма:
extension String {
var allPrefixes2: [Substring] {
return [""] + indices.map { index in self[..<index] }
}
}
hello.allPrefixes2 // ["", "H", "He", "Hel", "Hell", "Hello"]
Этот код также должен пройти по строке один раз, чтобы сгенерировать коллекцию индексов. Но как только это сделано, операция подстроки внутри map имеет сложность O(1). Это делает весь алгоритм O(n).
Заменяемый диапазон, не изменяемый Link to heading
Строка также соответствует RangeReplaceableCollection. Вот пример того, как вы можете заменить часть строки, сначала определив соответствующий диапазон в терминах индексов строки, а затем вызвав replaceSubrange. Заменяемая строка может иметь другую длину или даже быть пустой (что будет эквивалентно вызову removeSubrange):
var greeting = "Hello, world!"
if let comma = greeting.firstIndex(of: ",") {
greeting[..<comma] // Hello
greeting.replaceSubrange(comma..., with: " again.")
}
greeting // Hello again.
Как всегда, имейте в виду, что результаты могут быть неожиданными, если части заменяемой строки формируют новые графемные кластеры с соседними символами в оригинальной строке.
Одной из особенностей, которые строки не предоставляют, является MutableCollection. Этот протокол добавляет одну функцию к коллекции — это set для одноэлементного сабскрипта, в дополнение к get. Это не значит, что строки не изменяемы — как мы только что видели, у них есть несколько методов изменения. Но то, что вы не можете сделать, это заменить отдельный символ, используя оператор сабскрипта. Причина этого связана с символами переменной длины. Большинство людей, вероятно, могут интуитивно понять, что обновление одноэлементного сабскрипта будет происходить за постоянное время, как это происходит для Array. Но поскольку символ в строке может иметь переменную ширину, обновление одного символа может занять линейное время в зависимости от длины строки: изменение ширины одного элемента потребует перемещения всех последующих элементов вверх или вниз в памяти. Более того, индексы, которые идут после заменяемого индекса, станут недействительными из-за перемещения, что также неинтуитивно. По этим причинам вам нужно использовать replaceSubrange, даже если диапазон, который вы передаете, состоит всего из одного символа.
Индексы строк Link to heading
Большинство языков программирования используют целые числа для индексации строк, например, str[5] вернет шестой «символ» строки str (в зависимости от того, что подразумевается под «символом» в этом языке). Swift не позволяет этого. Почему? Ответ должен показаться вам знакомым: индексация должна выполняться за постоянное время (интуитивно, а также в соответствии с требованиями протокола Collection), и получение n-го символа невозможно без просмотра всех байтов, которые идут перед ним.
String.Index, который является типом индекса, используемым в String и его представлениях, представляет собой непрозрачное значение, которое по сути хранит смещение в байтах в представлении строки в памяти (обычно UTF-8). Это все еще операция O(n), если вы хотите вычислить индекс для n-го символа и должны начинать с начала строки, но как только у вас есть действительный индекс, индексация строки с его помощью займет только O(1) времени. И, что важно, нахождение следующего индекса после существующего индекса также быстро, потому что вы можете начать с байтового смещения существующего индекса — вам не нужно возвращаться к началу снова. Вот почему итерация по символам в строке в порядке (вперед или назад) эффективна.
Манипуляции с индексами строк основаны на тех же API Collection, которые вы используете с любой другой коллекцией. Легко упустить это сходство, поскольку коллекции, которые мы используем чаще всего — массивы — используют целочисленные индексы, и мы обычно используем простую арифметику для их манипуляции. Метод index(after:) возвращает индекс следующего символа:
let s = "abcdef"
let second = s.index(after: s.startIndex)
s[second] // b
Вы можете автоматизировать итерацию по нескольким символам за один раз с помощью метода index(_:offsetBy:):
// Продвиньтесь на 4 символа вперед.
let sixth = s.index(second, offsetBy: 4)
s[sixth] // f
Если существует риск продвинуться за конец строки, вы можете добавить параметр limitedBy:. Метод возвращает nil, если он достиг предела, не дойдя до целевого индекса:
let safeIdx = s.index(s.startIndex, offsetBy: 400, limitedBy: s.endIndex)
safeIdx // nil
Это, безусловно, больше кода, чем потребовалось бы для простых целочисленных индексов, но, опять же, в этом и заключается суть. Если бы Swift позволял целочисленную индексацию строк, искушение случайно написать ужасно неэффективный код (например, используя целочисленную индексацию внутри цикла) было бы слишком велико.
Тем не менее, для кого-то, кто привык работать с символами фиксированной ширины, работа со строками в Swift кажется сложной в начале — как вы будете ориентироваться без целочисленных индексов? И действительно, некоторые, казалось бы, простые задачи, такие как извлечение первых четырех символов строки, могут превратиться в монструозные конструкции, как эта:
s[..<s.index(s.startIndex, offsetBy: 4)] // abcd
Но, к счастью, возможность доступа к строке через интерфейс Collection также означает, что у вас есть несколько полезных техник под рукой. Большинство методов, которые работают с Array, также работают с String. Используя метод prefix, то же самое выглядит гораздо яснее:
s.prefix(4) // abcd
(Обратите внимание, что оба выражения возвращают Substring; вы можете преобразовать его обратно в String, обернув его в String.init. Мы поговорим больше о подстроках в следующем разделе.)
В качестве немного более сложного примера, извлечение месяца из строки даты можно выполнить полностью без выполнения каких-либо операций индексации над строкой:
let date = "2019-09-01"
date.split(separator: "-")[1] // 09
date.dropFirst(5).prefix(2) // 09
Для поиска конкретного символа вы можете использовать firstIndex(of:):
var hello = "Hello!"
if let idx = hello.firstIndex(of: "!") {
hello.insert(contentsOf: ", world", at: idx)
}
hello // Hello, world!
Метод insert(contentsOf:at:) вставляет другую коллекцию того же типа элементов (например, Character для строк) перед заданным индексом. Это не обязательно должна быть другая String; вы можете так же легко вставить массив символов в строку.
Установив, что непрозрачные индексы — это способ работы со строками, команда Swift признает, что существуют обоснованные случаи, когда вы просто хотите сделать «легкую» вещь. С этой целью они предложили добавить синтаксис индексации для целочисленных смещений ко всем типам коллекций, а не только к строкам. Основная команда в конечном итоге вернула предложение на доработку, чтобы учесть отзывы, которые возникли в ходе обзора, но эта доработка так и не произошла, возможно, потому что другие вещи были более актуальными.
ПарсингСтрок Link to heading
Конечно, есть также задачи, которые нельзя решить, просто используя API коллекций для строки: парсинг CSV-файла — хороший пример этого. Мы не можем наивно разбить строку по запятым, потому что запятые также могут встречаться внутри значений, заключенных в кавычки. Чтобы решить такие задачи, мы можем итерироваться по строке, символ за символом, отслеживая некоторое состояние. По сути, мы пишем очень простой парсер:
func parse(csv: String) -> [[String]] {
var result: [[String]] = [[]]
var currentField = ""
var inQuotes = false
for c in csv {
switch (c, inQuotes) {
// ...
}
}
return result
}
Сначала мы создаем result как массив массивов строк. Каждая строка представлена массивом строк, а строка CSV может содержать много строк. Переменная currentField служит буфером для сбора символов одного поля, пока мы итерируемся по строке. Наконец, логическая переменная inQuotes отслеживает, находимся ли мы в данный момент внутри строки, заключенной в кавычки. Это единственная часть состояния, которая нам нужна для этого простого парсера.
Теперь нам нужно заполнить случаи для оператора switch:
- (",", false) — запятая вне кавычек завершает текущее поле
- ("\n", false) — новая строка вне кавычек завершает текущую строку
- (""", _) — кавычка переключает логическую переменную
inQuotes - default — во всех остальных случаях мы добавляем текущий символ в
currentField
func parse(csv: String) -> [[String]] {
// ...
for c in csv {
switch (c, inQuotes) {
case (",", false):
result[result.endIndex - 1].append(currentField)
currentField.removeAll()
case ("\n", false):
result[result.endIndex - 1].append(currentField)
currentField.removeAll()
result.append([])
case ("\"", _):
inQuotes = !inQuotes
default:
currentField.append(c)
}
}
result[result.endIndex - 1].append(currentField)
return result
}
(Мы создаем временный кортеж, чтобы переключаться между двумя значениями одновременно. Вы можете помнить эту технику из главы о перечислениях.)
После цикла for нам все еще нужно добавить currentField в последний раз перед возвратом результата, потому что строка CSV может не заканчиваться новой строкой.
Давайте попробуем парсер CSV на примере:
let csv = #"""
"Values in quotes","can contain , characters"
"Values without quotes work as well:",42
"""#
parse(csv: csv)
/*
[["Values in quotes", "can contain , characters"],
["Values without quotes work as well:", "42"]]
*/
Строковый литерал выше использует синтаксис расширенных разделителей (обрамляя строковый литерал в символы #), что позволяет нам писать кавычки внутри строкового литерала, не экранируя их.
Умение писать такие небольшие парсеры значительно улучшает ваши навыки работы со строками. Таким образом, задачи, которые трудно или невозможно решить с помощью API коллекций или даже регулярных выражений, часто становятся легче для написания и чтения.
Парсер CSV выше не завершен, но он уже полезен. Он короткий, потому что нам не нужно отслеживать много состояния; есть только одна логическая переменная. С небольшим дополнительным трудом мы могли бы игнорировать пустые строки, игнорировать пробелы вокруг полей в кавычках и поддерживать экранирование кавычек внутри полей в кавычках (используя два символа кавычек). Вместо использования одной логической переменной для отслеживания состояния парсера, мы могли бы использовать перечисление, чтобы однозначно различать все возможные состояния.
Однако чем больше состояния мы добавляем в парсер, тем легче допустить ошибки в его реализации. Поэтому такой подход к парсингу в одном цикле целесообразен только для небольших парсеров. Если нам нужно отслеживать больше состояния, нам придется изменить стратегию с написания всего в одном цикле на разбиение парсера на несколько функций.
Подстроки Link to heading
Как и все коллекции, строка имеет специфический срез, или тип подпоследовательности, называемый подстрокой. Подстрока очень похожа на срез массива: это представление базовой строки с различными начальным и конечным индексами. Подстроки разделяют текстовое хранилище своих базовых строк. Это имеет огромное преимущество, так как срез строки является недорогой операцией. Создание переменной firstWord в следующем примере не требует дорогих копий или выделения памяти:
let sentence = "The quick brown fox jumped over the lazy dog."
let firstSpace = sentence.firstIndex(of: " ") ?? sentence.endIndex
let firstWord = sentence[..<firstSpace] // The
type(of: firstWord) // Substring
Недорогие операции среза особенно важны в циклах, где вы перебираете всю (возможно, длинную) строку, чтобы извлечь ее компоненты. Задачи, такие как нахождение всех вхождений слова в тексте или парсинг данных CSV, как мы делали выше, приходят на ум. Полезной операцией обработки строк в этом контексте является разделение. Метод split определен в Collection и возвращает массив подпоследовательностей (т.е. [Substring]). Его наиболее распространенный вариант определен следующим образом:
extension Collection where Element: Equatable {
public func split(separator: Element, maxSplits: Int = Int.max,
omittingEmptySubsequences: Bool = true) -> [SubSequence]
}
Вы можете использовать его следующим образом:
let poem = """
Over the wintry
forest, winds howl in rage
with no leaves to blow.
"""
let lines = poem.split(separator: "\n")
// ["Over the wintry", "forest, winds howl in rage", "with no leaves to blow."]
type(of: lines) // Array<Substring>
Это может выполнять функцию, аналогичную методу components(separatedBy:), который строка наследует от NSString, с добавленными конфигурациями для того, чтобы сбрасывать пустые компоненты или нет. Снова, никаких копий входной строки не создается. И поскольку существует другой вариант split, который принимает замыкание, он может делать больше, чем просто сравнивать символы. Вот пример примитивного алгоритма переноса слов, где замыкание захватывает счетчик длины строки на данный момент:
extension String {
func wrapped(after maxLength: Int = 70) -> String {
var lineLength = 0
let lines = self.split(omittingEmptySubsequences: false) { character in
if character.isWhitespace && lineLength >= maxLength {
lineLength = 0
return true
} else {
lineLength += 1
return false
}
}
return lines.joined(separator: "\n")
}
}
sentence.wrapped(after: 15)
/*
The quick brown
fox jumped over
the lazy dog.
*/
Или рассмотрите написание версии, которая принимает последовательность нескольких разделителей:
extension Collection where Element: Equatable {
func split<S: Sequence>(separators: S) -> [SubSequence]
where Element == S.Element {
return split { separators.contains($0) }
}
}
Таким образом, вы можете написать следующее:
"Hello, world!".split(separators: ",! ") // ["Hello", "world"]
StringProtocol Link to heading
Substring имеет почти такой же интерфейс, как и String. Это достигается через общий протокол, называемый StringProtocol, которому соответствуют оба типа. Поскольку почти весь API строк определен в StringProtocol, вы можете в основном работать с Substring так же, как и с String. Однако в какой-то момент вам придется преобразовать ваши подстроки обратно в экземпляры String; как и все срезы, подстроки предназначены только для краткосрочного хранения, чтобы избежать дорогостоящих копий во время операции. Когда операция завершена и вы хотите сохранить результаты или передать их в другую подсистему, вам следует создать новый String. Вы можете сделать это, инициализировав String с помощью Substring, как мы делаем в этом примере:
func lastWord(in input: String) -> String? {
// Обрабатываем входные данные, работая с подстроками.
let words = input.split(separators: [",", " "])
guard let lastWord = words.last else { return nil }
// Преобразуем в String для возврата.
return String(lastWord)
}
lastWord(in: "one, two, three, four, five") // Optional("five")
Обоснование для недопустимости долгосрочного хранения подстрок заключается в том, что подстрока всегда удерживает всю оригинальную строку. Подстрока, представляющая один символ огромной строки, будет удерживать всю строку в памяти, даже после того, как срок жизни оригинальной строки обычно закончится. Долгосрочное хранение подстрок, следовательно, эффективно вызовет утечки памяти, поскольку оригинальные строки должны оставаться в памяти, даже когда они больше не доступны.
Работая с подстроками во время операции и создавая новые строки только в конце, мы откладываем копии до последнего момента и гарантируем, что понесем затраты только за те копии, которые действительно необходимы. В приведенном выше примере мы разбиваем всю (потенциально длинную) строку на подстроки, но мы платим только за одну копию одной короткой подстроки в конце. (На мгновение игнорируйте, что этот алгоритм все равно неэффективен; итерация назад от конца до тех пор, пока мы не найдем первый разделитель, была бы лучшим подходом.)
Встреча с функцией, которая принимает только Substring, когда вы хотите передать String, менее распространена — большинство функций должны принимать либо String, либо любой тип, соответствующий StringProtocol. Но если вам действительно нужно передать Substring, самый быстрый способ — это использовать оператор подстроки с оператором диапазона ..., не указывая никаких границ:
// Подстрока, охватывающая всю строку.
let substring = sentence[...]
Вас может соблазнить воспользоваться всеми преимуществами существования StringProtocol и преобразовать все ваши API для работы с экземплярами StringProtocol, а не с обычными String. Но совет команды Swift — не делать этого:
Наш общий совет — придерживаться
String. Большинство API будет проще и понятнее, если использоватьString, а не делать их обобщенными (что само по себе может иметь свои затраты), и преобразование пользователя по пути в тех редких случаях, когда это необходимо, не является большой нагрузкой.
API, которые крайне вероятно будут использоваться с подстроками и в то же время не могут быть дополнительно обобщены на уровень Sequence или Collection, являются исключением из этого правила. Примером этого в стандартной библиотеке является метод joined. Стандартная библиотека предоставляет перегрузку для последовательностей с элементами, соответствующими StringProtocol:
extension Sequence where Element: StringProtocol {
/// Возвращает новую строку, конкатенируя элементы последовательности,
/// добавляя указанный разделитель между каждым элементом.
public func joined(separator: String = "") -> String
}
Это позволяет вам вызывать joined непосредственно на массиве подстрок (которые вы получили, например, из вызова split), не обходя массив и не копируя каждую подстроку в новую строку. Это более удобно и намного быстрее.
Инициализаторы для числовых типов, которые принимают строку и преобразуют ее в число, также принимают значения StringProtocol. Снова это особенно удобно, если вы хотите обработать массив подстрок:
let commaSeparatedNumbers = "1,2,3,4,5"
let numbers = commaSeparatedNumbers.split(separator: ",")
.compactMap { Int($0) }
numbers // [1, 2, 3, 4, 5]
Поскольку подстроки предназначены для краткосрочного хранения, обычно не рекомендуется возвращать одну из них из функции, если вы не работаете с API Sequence или Collection, которые возвращают срезы. Если вы пишете аналогичную функцию, которая имеет смысл только для строк, возвращение подстроки говорит читателям, что она не создает копию. Однако функции, которые создают новые строки, требующие выделения памяти, такие как uppercased(), всегда должны возвращать экземпляры String.
Если вы хотите расширить String новой функциональностью, размещение расширения на StringProtocol — хорошая идея, чтобы сохранить согласованность API между String и Substring. StringProtocol специально разработан для использования всякий раз, когда вы ранее расширяли String. Если вы хотите переместить существующие расширения из String в StringProtocol, единственное изменение, которое вам нужно сделать, — это заменить любое использование self в API, который принимает конкретный String, на String(self).
Имейте в виду, однако, что StringProtocol не предназначен как целевой объект соответствия для ваших собственных пользовательских строковых типов. Документация явно предостерегает от этого:
Не объявляйте новые соответствия с
StringProtocol. Только типыStringиSubstringстандартной библиотеки являются действительными типами соответствия.
Представления кодовых единиц Link to heading
Иногда необходимо опуститься на более низкий уровень абстракции и работать непосредственно с юникодными скалярными значениями или кодовыми единицами вместо символов Swift (т.е. графемных кластеров). Строка предоставляет три представления для этого: unicodeScalars, utf8 и utf16. Как и String, эти представления являются двунаправленными коллекциями, которые поддерживают все знакомые операции. И как и подстроки, представления разделяют хранилище строки; они просто представляют собой базовые байты другим способом.
Существует несколько распространенных причин, по которым вам может понадобиться работать с одним из представлений. Во-первых, возможно, вам действительно нужны кодовые единицы, например, для рендеринга на веб-странице, закодированной в UTF-8, или для взаимодействия с API, не относящимся к Swift, который ожидает определенного кодирования. Или, возможно, вам нужна информация о строке в определенном формате.
Например, предположим, что вы пишете клиент для Twitter. Хотя API Twitter ожидает, что строки будут закодированы в UTF-8, алгоритм подсчета символов Twitter основан на скалярах, нормализованных по NFC (по крайней мере, так было раньше — алгоритм стал более сложным в последние годы, но мы будем придерживаться предыдущего подхода ради этого примера). Поэтому, если вы хотите показать своим пользователям, сколько символов у них осталось, вот как вы можете это сделать:
let tweet = "Having in a cafe\u{301} in and enjoying the ."
let characterCount = tweet.precomposedStringWithCanonicalMapping.unicodeScalars.count
characterCount // 46
NFC-нормализация преобразует базовые буквы и комбинирующие знаки, такие как e с акцентом в “cafe\u{301}”, в их предварительно составленные формы. Свойство precomposedStringWithCanonicalMapping определено в Foundation.
UTF-8 является де-факто стандартом для хранения текста или его передачи по интернету. Поскольку представление utf8 является коллекцией, вы можете использовать его для передачи необработанных байтов UTF-8 в любой другой API, который принимает последовательность байтов, например, в инициализаторы для Data или Array:
let utf8Bytes = Data(tweet.utf8)
utf8Bytes.count // 62
Представление UTF-8 в String также имеет наименьшие накладные расходы среди всех представлений кодовых единиц, потому что UTF-8 является родным форматом в памяти для строк Swift.
Обратите внимание, что коллекция utf8 не включает завершающий нулевой байт. Если вам нужно представление с завершающим нулем, используйте метод withCString или свойство utf8CString у String. Последнее возвращает массив байтов:
let nullTerminatedUTF8 = tweet.utf8CString
nullTerminatedUTF8.count // 63
Метод withCString вызывает функцию, которую вы предоставляете с указателем на содержимое строки, закодированное в UTF-8 и завершающееся нулем. Это полезно, если вам нужно вызвать C API, который ожидает char*. Во многих случаях вам даже не нужно явное вызов withCString, потому что компилятор может автоматически преобразовать строку Swift в C-строку для вызова функции. Например, вот вызов функции strlen из стандартной библиотеки C:
strlen(tweet) // 62
Мы увидим больше примеров этого в главе о совместимости. В большинстве случаев (если базовое хранилище строки уже в UTF-8) это преобразование почти не имеет затрат, потому что Swift может передать прямой указатель в хранилище строки в C (поскольку хранилище действительно включает завершающий нулевой байт). Если строка имеет другое кодирование в памяти, компилятор автоматически вставит код для транскодирования содержимого и скопирует это содержимое во временный буфер.
Представление utf16 имеет особое значение, потому что API Foundation традиционно рассматривают строки как коллекции кодовых единиц UTF-16. Хотя интерфейс NSString прозрачно связывается со Swift.String, тем самым обрабатывая преобразования неявно для вас, другие API Foundation, такие как NSRegularExpression или NSAttributedString, часто ожидают входные данные в виде данных UTF-16. Мы увидим пример этого в разделе о строках и Foundation.
Вторая причина для использования представлений кодовых единиц заключается в том, что работа с кодовыми единицами, а не с полностью составленными символами может быть быстрее. Это связано с тем, что алгоритм разбиения графем Unicode довольно сложен и требует дополнительного предсказания, чтобы определить начало следующего графемного кластера. Однако обход строки как коллекции Character стал намного быстрее в последние годы, поэтому обязательно измерьте, стоит ли (относительно небольшое) ускорение потери корректности Unicode. Как только вы опускаетесь до одного из представлений кодовых единиц, вы должны быть уверены, что ваш конкретный алгоритм работает правильно в этом контексте. Например, использование представления UTF-8 для разбора JSON будет приемлемым, потому что все специальные символы, которые интересуют парсер (такие как запятые, кавычки или фигурные скобки), могут быть представлены в одной кодовой единице; не имеет значения, что некоторые строки в данных JSON могут содержать сложные последовательности эмодзи. (Как упоминалось ранее, обратите особое внимание на переводы строк. “\r\n” является одним символом, но это два скаляра или кодовые единицы.) С другой стороны, поиск всех вхождений слова в строке не будет работать правильно на представлении кодовых единиц, если вы хотите, чтобы алгоритм поиска находил разные нормализованные формы строки поиска.
Одной из желаемых функций, которую не предоставляют ни одно из представлений кодовых единиц, является произвольный доступ. Следствием этого является то, что String и его представления плохо подходят для алгоритмов, которые требуют произвольного доступа. Огромное большинство задач обработки строк должно работать нормально с последовательным обходом, особенно поскольку алгоритм всегда может хранить подстроки для фрагментов, которые он хочет иметь возможность повторно посещать за постоянное время. Если вам абсолютно нужен произвольный доступ, вы всегда можете преобразовать саму строку или одно из ее представлений в массив и работать с ним, как с Array(str) или Array(str.utf8). Для максимальной производительности за счет безопасности также существует withUTF8(str) { buffer in ... }, который предоставляет временный указатель на хранилище строки.
IndexSharing Link to heading
Строки и их представления имеют один и тот же тип индекса — String.Index. Это означает, что вы можете использовать индекс, полученный из строки, для доступа к одному из представлений. В следующем примере мы ищем в строке символ “é” (который состоит из двух скалярных значений: буквы e и комбинирующего акцента). Полученный индекс ссылается на первое скалярное значение в представлении Unicode:
let pokemon = "Poke\u{301}mon" // Pok é mon
if let index = pokemon.firstIndex(of: "é") {
let scalar = pokemon.unicodeScalars[index] // e
String(scalar) // e
}
Это работает отлично, пока вы движетесь вниз по лестнице абстракций, от символов к скалярным значениям, к кодовым единицам UTF-8 или UTF-16. Движение в обратном направлении может привести к неожиданным результатам, поскольку не каждый действительный индекс в одном из представлений кодовых единиц находится на границе символа:
let flag = "🇳🇿" // 🇳 + 🇿
// Этот инициализатор создает индекс по смещению UTF-16.
let someUTF16Index = String.Index(utf16Offset: 2, in: flag)
flag[someUTF16Index] // 🇿
String.Index имеет набор методов (samePosition(in:)) и инициализаторов, которые могут вернуть nil (String.Index.init?(_:within:)) для преобразования индексов между представлениями. Эти методы возвращают nil, если данный индекс не имеет точного соответствующего положения в указанном представлении. Например, попытка преобразовать позицию комбинирующего акцента в представлении скалярных значений в действительный индекс в строке завершится неудачей, поскольку комбинирующий символ не имеет собственного положения в строке:
if let accentIndex = pokemon.unicodeScalars.firstIndex(of: "\u{301}") {
accentIndex.samePosition(in: pokemon) // nil
}
Строки и Foundation Link to heading
Тип String в Swift имеет очень тесную связь с его аналогом из Foundation, NSString. Любой экземпляр строки может быть преобразован в NSString с помощью оператора as, а API Objective-C, которые принимают или возвращают NSString, автоматически переводятся на использование String. Начиная с Swift 5.5, String по-прежнему не обладает многими функциями, которые есть у NSString, но поскольку строки являются такими фундаментальными типами, и постоянное приведение к NSString было бы неудобным, String получает специальное обращение от компилятора: когда вы импортируете Foundation, члены NSString становятся непосредственно доступными в экземплярах String, что делает строки Swift значительно более функциональными, чем они были бы в противном случае. Наличие дополнительных функций, безусловно, является хорошей вещью, но это может сделать работу со строками несколько запутанной. Во-первых, если вы забудете импортировать Foundation, вы можете задаться вопросом, почему некоторые методы недоступны. История Foundation как фреймворка Objective-C также склонна делать API NSString немного неуместными рядом со стандартной библиотекой, хотя бы из-за различных соглашений о наименовании. И, наконец, пересечение между наборами функций двух библиотек иногда означает, что существуют два API с совершенно разными именами, которые выполняют почти идентичные задачи. Если вы являетесь давним разработчиком Cocoa и изучали API NSString до появления Swift, это, вероятно, не будет большой проблемой, но это может запутать новичков.
Мы уже видели один пример — метод split стандартной библиотеки против components(separatedBy:) в Foundation — и есть множество других несоответствий: Foundation использует перечисления ComparisonResult для предикатов сравнения, в то время как стандартная библиотека основана на булевых предикатах; методы, такие как trimmingCharacters(in:) и components(separatedBy:), принимают CharacterSet в качестве аргумента, что является неудачным названием в Swift (об этом позже); и чрезвычайно мощный метод enumerateSubstrings(in:options:_:), который может итерировать по строке кусками графемных кластеров, слов, предложений или абзацев, работает со строками и диапазонами, в то время как соответствующий API стандартной библиотеки использовал бы подстроки. (Стандартная библиотека также могла бы предоставить ту же функциональность в виде ленивой последовательности, что было бы очень круто.) API Foundation часто учитывают локализацию, т.е. они могут сравнивать или перечислять строки на основе специфических для локализации критериев. Стандартная библиотека намеренно игнорирует этот важный аспект обработки текста.
Следующий пример перечисляет слова в строке. Замыкание обратного вызова вызывается один раз для каждого найденного слова:
let sentence = """
The quick brown fox jumped \
over the lazy dog.
"""
var words: [String] = []
sentence.enumerateSubstrings(in: sentence.startIndex..., options: .byWords) {
(word, range, _, _) in
guard let word = word else { return }
words.append(word)
}
words
// ["The", "quick", "brown", "fox", "jumped", "over", "the", "lazy", "dog"]
Чтобы получить обзор всех членов NSString, импортированных в String, ознакомьтесь с NSStringAPI.swift в исходном коде Foundation.
Из-за несоответствия между нативными кодировками в памяти строк Swift (UTF-8) и NSString (UTF-16) существует дополнительная стоимость производительности, когда строка Swift должна быть преобразована в NSString. Это означает, что передача нативной строки Swift в API Foundation, таком как enumerateSubstrings(in:options:using:), может быть не такой быстрой, как передача NSString — метод может предполагать, что он может перемещаться по строке в терминах смещений UTF-16 за постоянное время, но это будет линейная операция по времени для строки Swift. Чтобы смягчить этот эффект, Swift реализует сложное кэширование индексов для достижения амортизированных характеристик постоянного времени.
Другие API Foundation, связанные со строками Link to heading
Сказав все это, нативные API NSString в целом приятно использовать со строками Swift, потому что большая часть работы по связыванию выполняется за вас. Некоторые другие API Foundation, которые работают со строками, гораздо сложнее в использовании, потому что Apple еще не написала специальные обертки для Swift для них. Рассмотрим NSRegularExpression, класс Foundation, который представляет регулярные выражения для сопоставления строк с шаблонами поиска. Чтобы успешно использовать этот класс из Swift, вам нужно быть в курсе следующего: → Хотя все API NSRegularExpression, которые изначально принимают NSString, теперь принимают Swift.String, весь API все еще основан на концепции NSString как коллекции кодовых единиц UTF-16. Foundation использует диапазоны типа NSRange для представления “подстрок”, и они неизменно работают в терминах кодовых единиц UTF-16. → Менее важно, что частое связывание между String и NSString может привести к неожиданным затратам на производительность. См. сессию 229 на WWDC 2018 для подробностей об этом.
Например, метод rangeOfFirstMatch(in:options:range:) возвращает местоположение первого совпадения регулярного выражения как NSRange, а не как Range<String.Index>. Кроме того, поскольку Objective-C не имеет опционалов, метод возвращает значение-сигнал NSRange(location:NSNotFound,length:0), чтобы обозначить “не найдено”.
NSRange — это структура, которая содержит два целых поля — location и length:
public struct NSRange {
public var location: Int
public var length: Int
}
В контексте строк поля указывают на сегмент строки в терминах кодовых единиц UTF-16. Swift предоставляет инициализаторы для преобразования между Range<String.Index> и NSRange. Это делает работу с NSRange менее подверженной ошибкам, чем необходимость постоянно помнить о переходе к представлению UTF-16; однако это не сокращает дополнительный код, необходимый для перевода туда и обратно. Вот пример того, как вы можете создать регулярное выражение и сопоставить его с строкой поиска:
extension NSRegularExpression {
func firstMatch(in input: String) -> Substring? {
// NSRegularExpression ожидает "подстроку", в которой
// будет производиться поиск, как NSRange.
var inputRange = NSRange(input.startIndex..., in: input)
let utf16Length = inputRange.length
while true {
let matchNSRange = self.rangeOfFirstMatch(in: input, range: inputRange)
guard matchNSRange != NSRange(location: NSNotFound, length: 0) else {
// Шаблон не найден.
return nil
}
// Преобразуем NSRange в диапазон Swift.
guard let matchRange = Range(matchNSRange, in: input) else {
// Совпадение не попадает на границу символа → начинаем заново.
inputRange.location = matchNSRange.location + matchNSRange.length
inputRange.length = utf16Length - inputRange.location
continue
}
return input[matchRange]
}
}
}
let input = "Мои любимые числа: -9, 27 и 81."
let regex = try! NSRegularExpression(pattern: "-?[0-9]+")
if let match = regex.firstMatch(in: input) {
print("Найдено: \(match)")
} else {
print("Не найдено")
}
// Найдено: -9
Строка let regex = try! ... является хорошим примером того, где принудительное извлечение (или, скорее, его эквивалент для обработки ошибок) оправдано. Мы инициализируем NSRegularExpression с литералом строки. Если это приведет к сбою во время разработки из-за того, что мы передали недопустимое регулярное выражение, мы можем легко это исправить, но это никогда не приведет к сбою в производстве. См. главу об опционалах для получения дополнительной информации об этом.
Вы можете задаться вопросом, почему нам нужен цикл в приведенном выше коде. NSRegularExpression рассматривает строки в терминах юникодных скалярных значений. Это означает, что она может найти совпадение на скаляре, который является частью графемного кластера (также известного как символ). Вот пример, где мы ищем один из двух скалярных значений, которые составляют эмодзи флага: 🇧🇷
let flag = "🇧🇷"
let regex2 = try! NSRegularExpression(pattern: "🇧")
regex2.rangeOfFirstMatch(in: flag, range: NSRange(flag.startIndex..., in: flag))
// {0, 2}
Это допустимое совпадение с точки зрения NSRegularExpression, но, вероятно, это не то, что ожидает или хочет программист на Swift. Более того, поскольку совпадение попадает на часть символа, это не допустимый диапазон в строке Swift. Когда мы не можем преобразовать NSRange совпадения обратно в Range<String.Index>, мы игнорируем совпадение и начинаем заново с позиции совпадения.
В заключение, нам удалось написать красивую и безопасную обертку для оригинального API, скрыв: → Все использования NSRange → Перевод между различными способами, которыми String и Foundation работают с юникодом → Использование опционалов вместо значений-сигналов для представления “нет совпадения” → Возвращение подстроки вместо диапазона
Давайте используем тот же подход для поиска всех совпадений в строке, а не только первого. Эквивалентный API NSRegularExpression называется matches(in:options:range:), и он возвращает массив объектов NSTextCheckingResult. Это довольно сложный класс, но для наших целей достаточно знать, что он предоставляет NSRange совпадения, которое он представляет. Финальный код на самом деле немного проще, чем firstMatch(in:) выше, потому что мы можем опустить цикл:
extension NSRegularExpression {
func matches(in input: String) -> [Substring] {
let inputRange = NSRange(input.startIndex..., in: input)
let matches = self.matches(in: input, range: inputRange)
return matches.compactMap { match in
guard match.range != NSRange(location: NSNotFound, length: 0) else {
// NSTextChecking не должен иметь "nil" диапазон.
fatalError("Не должно происходить")
}
guard let matchRange = Range(match.range, in: input) else {
// Совпадение не попадает на границу символа.
return nil
}
return input[matchRange]
}
}
}
regex.matches(in: input) // ["-9", "27", "81"]
Мы думаем, что вы согласитесь, что это немного работы, чтобы сделать эти API Foundation более идиоматичными для Swift. И необходимость использовать API, который работает с юникодом другим способом, не просто неудобство; это может легко привести к трудноуловимым ошибкам. Вероятно, в недалеком будущем Swift получит нативный синтаксис регулярных выражений, но до тех пор это лучший способ.
Помимо NSRegularExpression, еще одним классом Foundation с аналогичным несоответствием является NSAttributedString, который используется для представления богатого текста. Apple представила современную версию этого класса под названием struct AttributedString, которая гораздо лучше вписывается в Swift. Мы рекомендуем использовать его, если ваша целевая платформа это позволяет.
ДиапазоныСимволов Link to heading
Говоря о диапазонах, вы, возможно, пытались перебрать диапазон символов и обнаружили, что это не работает:
let lowercaseLetters = ("a" as Character)..."z"
for c in lowercaseLetters { // Ошибка
// ...
}
(Приведение к типу Character важно, потому что типом строкового литерала по умолчанию является String; нам нужно сообщить компилятору, что мы хотим диапазон символов.) В главе о встроенных коллекциях мы говорили о причинах, по которым это не работает: Character не соответствует протоколу Strideable, что является требованием для того, чтобы диапазоны стали подсчитываемыми, а следовательно, коллекциями. Единственное, что вы можете сделать с диапазоном символов, — это сравнить другие символы с ним, т.е. проверить, находится ли символ внутри диапазона:
lowercaseLetters.contains("A") // false
lowercaseLetters.contains("c") // true
lowercaseLetters.contains("é") // false
Тип, для которого понятие подсчитываемых диапазонов имеет смысл, — это Unicode.Scalar — по крайней мере, если вы придерживаетесь ASCII или других хорошо упорядоченных подмножеств каталога Unicode. Скалярные значения имеют четко определенный порядок по их значениям кодовых точек, и между любыми двумя границами всегда существует конечное количество скалярных значений. Unicode.Scalar по умолчанию не является Strideable, но мы можем добавить соответствие задним числом:
extension Unicode.Scalar: Strideable {
public typealias Stride = Int
public func distance(to other: Unicode.Scalar) -> Int {
return Int(other.value) - Int( self .value)
}
public func advanced(by n: Int) -> Unicode.Scalar {
return Unicode.Scalar(UInt32(Int(value) + n))!
}
}
(Мы игнорируем тот факт, что суррогатные кодовые точки 0xD800 до 0xDFFF не являются допустимыми значениями скалярных Unicode. Создание диапазона, который пересекает этот регион, является ошибкой программиста.) Соответствие типа, который вы не владеете, протоколу, которым вы не владеете, может быть проблематичным и обычно не рекомендуется. Это может вызвать конфликты, если другие библиотеки, которые вы используете, добавляют то же самое расширение, или если оригинальный поставщик позже добавляет то же самое соответствие (с потенциально другой реализацией). Часто лучше создать обертку и добавить соответствие протоколу к этому типу. Мы вернемся к этому в главе о протоколах. Добавление соответствия Strideable к Unicode.Scalar позволяет нам использовать диапазон скалярных Unicode как удобный способ генерации массива символов:
let lowercase = ("a" as Unicode.Scalar)..."z"
Array(lowercase.map(Character.init))
/*
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n",
"o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]
*/
CharacterSet Link to heading
Давайте рассмотрим еще один интересный тип Foundation, а именно CharacterSet. 🚒 CharacterSet — это эффективный способ хранения набора кодовых точек Unicode. Он часто используется для проверки, содержит ли конкретная строка только символы из определенного подмножества символов, таких как алфавитно-цифровые символы или десятичные цифры. Название CharacterSet, заимствованное из Objective-C, не совсем удачно в Swift, потому что CharacterSet несовместим с типом Character в Swift. Более подходящее название было бы UnicodeScalarSet.
Мы можем проиллюстрировать это, создав набор из нескольких сложных эмодзи. Кажется, что набор содержит только два эмодзи, которые мы добавили, но проверка на принадлежность третьему эмодзи проходит успешно, потому что эмодзи пожарного на самом деле является последовательностью женщина + ZWJ + пожарная машина:
let favoriteEmoji = CharacterSet("👩🚒👨🎤".unicodeScalars)
// Неправильно! Или нет?
favoriteEmoji.contains("👨") // true
CharacterSet предоставляет ряд фабричных инициализаторов, таких как .alphanumerics и .whitespacesAndNewlines. Большинство из них соответствуют официальным категориям символов Unicode (каждой кодовой точке присваивается категория, такая как “Буква” или “Несмещающий знак”). Эти категории охватывают все письменности, а не только ASCII или Latin-1, поэтому количество членов в этих предопределенных наборах часто огромное. Поскольку CharacterSet соответствует SetAlgebra, мы можем комбинировать несколько наборов символов, используя операции над множествами, такие как объединения или пересечения.
Свойства Unicode Link to heading
Часть функциональности CharacterSet была интегрирована в Unicode.Scalar в Swift 5. Теперь нам больше не нужно использовать тип Foundation для проверки принадлежности скалярного значения к официальным категориям Unicode, так как они теперь доступны напрямую в виде свойств Unicode.Scalar, таких как isEmoji или isWhitespace. Чтобы избежать загромождения основного пространства имен Unicode.Scalar, свойства Unicode находятся в пространстве имен под названием properties:
("😀" as Unicode.Scalar).properties.isEmoji // true
("∫∫" as Unicode.Scalar).properties.isMath // true
Посмотрите документацию по Unicode.Scalar.Properties для полного списка. Большинство из этих свойств являются логическими, но не все из них таковы: также есть такие вещи, как age (версия Unicode, когда скаляр был введен), name (официальное имя Unicode), numericValue (полезно для дробей, которые имеют свой собственный кодовый пункт или числа в нелатинских скриптах) и generalCategory (перечисление, описывающее «первую, наиболее обычную категоризацию» скаляра).
Например, чтобы перечислить кодовый пункт, имя и общую категорию для каждого скалярного значения, которое составляет строку, требуется лишь немного форматирования строки:
"I’m a \(unicodeScalars.map { scalar -> String in
let codePoint = "U+\(String(scalar.value, radix: 16, uppercase: true))"
let name = scalar.properties.name ?? "(no name)"
return "\(codePoint): \(name) – \(scalar.properties.generalCategory)"
}.joined(separator: "\n"))"
U+49: LATIN CAPITAL LETTER I – uppercaseLetter
U+2019: RIGHT SINGLE QUOTATION MARK – finalPunctuation
U+6D: LATIN SMALL LETTER M – lowercaseLetter
U+20: SPACE – spaceSeparator
U+61: LATIN SMALL LETTER A – lowercaseLetter
U+20: SPACE – spaceSeparator
U+1F469: WOMAN – otherSymbol
U+1F3FD: EMOJI MODIFIER FITZPATRICK TYPE-4 – modifierSymbol
U+200D: ZERO WIDTH JOINER – format
U+1F692: FIRE ENGINE – otherSymbol
U+2E: FULL STOP – otherPunctuation
Свойства скалярного значения Unicode находятся на довольно низком уровне и намеренно используют иногда неясную терминологию стандарта Unicode. Часто полезно иметь аналогичные категории на уровне Character, поэтому Swift 5 также добавил множество связанных свойств в Character. Вот некоторые примеры:
Character("4").isNumber // true
Character("$").isCurrencySymbol // true
Character("\n").isNewline // true
В отличие от свойств скалярного значения Unicode, эти категории в Character не являются официальной частью спецификации Unicode, потому что Unicode только классифицирует скаляры, а не расширенные кластеры графем. Стандартная библиотека старается предоставить разумную информацию о природе символа, но из-за большого количества поддерживаемых скриптов и способности Unicode комбинировать скаляры в бесконечных комбинациях эти категории не являются точными и могут не совпадать с тем, что предоставляют другие инструменты или языки программирования. Они также могут эволюционировать со временем.
Внутренняя структура строк и символов Link to heading
Как и другие типы коллекций в стандартной библиотеке, строки являются коллекциями с семантикой значений и используют стратегию “копирование при записи”. Экземпляр String хранит ссылку на буфер, который содержит фактические данные символов. Когда вы создаете копию строки (через присваивание или передавая ее в функцию) или создаете подстроки, все экземпляры используют один и тот же буфер. Данные символов копируются только тогда, когда экземпляр изменяется, в то время как он делит свой буфер символов с одним или несколькими другими экземплярами. Для получения дополнительной информации о копировании при записи смотрите главу о структурах и классах.
String использует UTF-8 в качестве своего представления в памяти для нативных строк Swift. Вы можете использовать это знание в своих интересах, если вам требуется максимальная производительность — обход представления UTF-8 может быть немного быстрее, чем обход представления UTF-16 или представления юникодных скалярных значений. Кроме того, UTF-8 является естественным форматом для большинства операций со строками, поскольку большинство исходных данных из файлов или интернета используют кодировку UTF-8.
Строки, полученные из Objective-C, поддерживаются классом NSString. В этом случае NSString действует непосредственно как буфер строки Swift, что делает мост между ними эффективным. Строка, поддерживаемая NSString, будет преобразована в нативную строку Swift, когда она будет изменена. Для небольших строк длиной до 15 кодовых единиц UTF-8 (или 10 кодовых единиц на 32-битных платформах) существует специальная оптимизация, которая полностью избегает выделения буфера. Поскольку строки имеют ширину 16 байт, кодовые единицы небольших строк могут храниться встраиваемыми. Хотя 15 кодовых единиц UTF-8 могут показаться не так уж много, этого достаточно для многих строк. Например, в форматах, удобных для машинного чтения, таких как JSON, многие ключи и значения (например, числа и логические значения) помещаются в эту длину, особенно потому, что они часто используют только символы ASCII.
Эта оптимизация также используется для внутреннего представления типа Character (упрощенно из исходного кода стандартной библиотеки):
public struct Character {
internal var _str: String
internal init(unchecked str: String) {
self._str = str
// ...
}
}
Символ представляется внутренне как строка длиной один. Поскольку почти все символы помещаются в 15 байт UTF-8 (длинные последовательности эмодзи могут быть наиболее распространенным исключением), оптимизация небольших строк означает, что подавляющее большинство значений Character не требует выделения памяти в куче или подсчета ссылок.
Строковые литералы Link to heading
На протяжении этой главы мы использовали String("Hello") и "Hello" практически взаимозаменяемо, но они разные. "" — это строковый литерал, так же как и литералы массивов, рассмотренные в главе о протоколах коллекций. Вы можете сделать свои собственные типы инициализируемыми из строкового литерала, соответствуя протоколу ExpressibleByStringLiteral.
Строковые литералы немного сложнее, чем литералы массивов, потому что они являются частью иерархии из трех протоколов: ExpressibleByStringLiteral, ExpressibleByExtendedGraphemeClusterLiteral и ExpressibleByUnicodeScalarLiteral. Каждый из них определяет инициализатор для создания типа из каждого вида литерала, но если вам не нужна детализированная логика, основанная на том, создается ли значение из одного скалярного значения или кластера, вам нужно реализовать только строковую версию; остальные охватываются стандартными реализациями, которые ссылаются на инициализатор строкового литерала.
В качестве примера пользовательского типа, принимающего протокол ExpressibleByStringLiteral, мы определяем тип SafeHTML. Это на самом деле просто обертка вокруг строки, но с добавленной безопасностью типов. Когда мы используем значение этого типа, мы можем быть уверены, что потенциально опасные HTML-теги, которые оно содержит, были экранированы, чтобы избежать угрозы безопасности:
extension String {
var htmlEscaped: String {
// Заменяем все открывающие и закрывающие угловые скобки.
// "Настоящая" реализация была бы более сложной.
return replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
}
}
struct SafeHTML {
private(set) var value: String
init(unsafe html: String) {
self.value = html.htmlEscaped
}
}
Мы могли бы использовать этот тип, чтобы убедиться, что API наших представлений принимает только значения, которые правильно экранированы. Недостаток в том, что нам придется вручную оборачивать многие строковые литералы в нашем коде в SafeHTML. К счастью, мы можем соответствовать ExpressibleByStringLiteral, чтобы избежать этой накладной работы:
extension SafeHTML: ExpressibleByStringLiteral {
public init(stringLiteral value: StringLiteralType) {
self.value = value
}
}
Это предполагает, что строковые литералы в нашем коде всегда безопасны (что является разумным предположением, поскольку мы сами их вводили):
let safe: SafeHTML = "<p>Угловые скобки в литералах не экранируются</p>"
// SafeHTML(value: "<p>Угловые скобки в литералах не экранируются</p>")
Выше мы должны явно указать тип SafeHTML, в противном случае safe будет типа String. Однако мы можем опустить явные типы в контекстах, где компилятор уже знает тип, таких как при присвоении свойств или вызовах функций.
Интерполяция строк Link to heading
Интерполяция строк, т.е. вставка выражений в строковые литералы (такие как “ab=(ab)”), существует в Swift с самого начала. Swift 5 представил публичный API для использования интерполяции строк для пользовательских типов. Мы можем использовать этот API, чтобы улучшить тип SafeHTML, упомянутый выше. Часто нам нужно писать строковые литералы, содержащие HTML-теги с некоторыми пользовательскими данными между ними:
let input = ... // получено от пользователя, небезопасно!
let html = "<li>Username: \(input)</li>"
input должен быть экранирован, потому что он поступает из ненадежного источника, но сегменты строкового литерала должны оставаться нетронутыми, так как мы хотим записать HTML-теги. Мы можем достичь этого, реализовав правило интерполяции строк для нашего типа SafeHTML. API интерполяции строк Swift состоит из двух протоколов: ExpressibleByStringInterpolation и StringInterpolationProtocol. Первый должен быть принят типом, который должен быть сконструирован с помощью интерполяции строк. Второй может быть принят либо тем же типом, либо отдельным типом и содержит несколько методов для поэтапного построения интерполированных значений.
ExpressibleByStringInterpolation наследуется от ExpressibleByStringLiteral. Мы реализовали последний для типа SafeHTML выше, поэтому можем пропустить его здесь. Мы соответствуем ExpressibleByStringInterpolation, реализовав инициализатор, который может создать значение SafeHTML из значения StringInterpolationProtocol. Для этого примера мы будем использовать тот же тип, SafeHTML, чтобы соответствовать StringInterpolationProtocol:
extension SafeHTML: ExpressibleByStringInterpolation {
init(stringInterpolation: SafeHTML) {
self.value = stringInterpolation.value
}
}
Протокол StringInterpolationProtocol имеет три требования: инициализатор, метод appendLiteral и один или несколько методов appendInterpolation. У Swift есть тип по умолчанию, соответствующий этому протоколу, DefaultStringInterpolation, который обрабатывает интерполяцию строк, которую мы получаем бесплатно из стандартной библиотеки. Мы хотим предоставить пользовательский тип с методом appendInterpolation, который экранирует интерполированное значение:
extension SafeHTML: StringInterpolationProtocol {
init(literalCapacity: Int, interpolationCount: Int) {
self.value = ""
value.reserveCapacity(literalCapacity)
}
mutating func appendLiteral(_ literal: String) {
value += literal
}
mutating func appendInterpolation<T>(_ x: T) {
self.value += String(describing: x).htmlEscaped
}
}
Инициализатор информирует тип интерполяции о приблизительной емкости, необходимой для хранения всех объединенных литералов, а также о количестве интерполяций, которые мы ожидаем. Мы могли бы игнорировать эти два параметра и просто инициализировать value пустой строкой. Тем не менее, хорошей практикой является хотя бы передать значение literalCapacity в String.reserveCapacity. Это говорит строке выделить достаточно памяти для ожидаемого размера, избегая множественных дорогих перераспределений по пути. Это не идеально, потому что мы не знаем заранее, насколько большими будут интерполированные сегменты, но это лучше, чем ничего. Мы могли бы улучшить это, добавив оценочный размер (например, 10 байт) для каждого сегмента интерполяции.
Метод appendLiteral просто добавляет строку к свойству value, поскольку мы считаем строковые литералы безопасными по умолчанию (так же, как и с ExpressibleByStringLiteral выше). Метод appendInterpolation(_:) принимает входной параметр любого типа и использует String(describing:), чтобы превратить его в строку. Мы экранируем эту строку перед добавлением ее к value.
Поскольку метод appendInterpolation не имеет метки у параметра, мы можем использовать его так же, как используем стандартную интерполяцию строк Swift:
let unsafeInput = "<script>alert('Oops!')</script>"
let safe: SafeHTML = "<li>Username: \(unsafeInput)</li>"
safe
/*
SafeHTML(value: "<li>Username:
<script>alert(\'Oops!\')</script></li>")
*/
Компилятор переводит интерполированную строку в серию вызовов appendLiteral и appendInterpolation на нашем пользовательском типе StringInterpolationProtocol, давая нам возможность хранить эти данные так, как мы считаем нужным. Как только все литералы и сегменты интерполяции были обработаны, полученное значение передается в инициализатор init(stringInterpolation:), чтобы создать значение SafeHTML.
В нашем случае мы выбрали соответствовать одному и тому же типу как ExpressibleByStringInterpolation, так и StringInterpolationProtocol, потому что они имеют одинаковую структуру (оба требуют только одного строкового свойства). Тем не менее, возможность использовать отдельные типы полезна, когда структура данных, необходимая для построения интерполяции строк, отличается от структуры типа, создаваемого интерполяцией.
Тем не менее, мы можем сделать гораздо больше с интерполяцией строк. По сути, синтаксис \(...) является сокращением для вызова метода appendInterpolation, т.е. мы можем использовать несколько параметров и меток. Мы можем воспользоваться этим поведением, чтобы добавить “сырой” интерполяции, которая позволяет нам интерполировать значения без их экранирования:
extension SafeHTML {
mutating func appendInterpolation<T>(raw x: T) {
self.value += String(describing: x)
}
}
let star = "<sup>*</sup>"
let safe2: SafeHTML = "<li>Username\(raw: star): \(unsafeInput)</li>"
safe2
/*
SafeHTML(value: "<li>Username<sup>*</sup>:
<script>alert(\'Oops!\')</script></li>")
*/
Пользовательские строковые описания Link to heading
Функции, такие как print, String(describing:) и интерполяция строк, написаны так, чтобы принимать любой тип, независимо от того, что это. Даже без какой-либо настройки результаты, которые вы получаете, могут быть приемлемыми, потому что структуры по умолчанию выводят свои свойства:
let safe: SafeHTML = "<p>Hello, World!</p>"
print(safe)
// выводит SafeHTML(value: "<p>Hello, World!</p>")
Тем не менее, вы можете захотеть что-то немного красивее, особенно если ваш тип содержит приватные переменные, которые вы не хотите отображать. Но не бойтесь! Вам всего лишь нужно немного времени, чтобы привести ваш пользовательский тип к протоколу CustomStringConvertible, что даст ему красиво отформатированный вывод, когда он будет передан в print:
extension SafeHTML: CustomStringConvertible {
var description: String {
return value
}
}
Теперь, если кто-то преобразует значение SafeHTML в строку различными способами — используя его с функцией вывода, такой как print, передавая его в String(describing:) или используя его в какой-либо интерполяции строк — он просто выведет его строковое значение:
print(safe) // <p>Hello, World!</p>
Существует также CustomDebugStringConvertible, который вы можете реализовать, чтобы предоставить другой формат вывода для целей отладки. Он используется, когда кто-то вызывает String(reflecting:):
extension SafeHTML: CustomDebugStringConvertible {
var debugDescription: String {
return "SafeHTML: \(value)"
}
}
String(reflecting:) использует CustomStringConvertible, если CustomDebugStringConvertible не реализован, и наоборот. Аналогично, String(describing:) использует CustomDebugStringConvertible, если CustomStringConvertible недоступен. Часто CustomDebugStringConvertible не стоит реализовывать, если ваш тип простой. Однако, если ваш пользовательский тип является контейнером, вероятно, будет вежливо привести его к CustomDebugStringConvertible, чтобы напечатать отладочные версии элементов, которые он содержит. И если вы делаете что-то необычное при выводе для целей отладки, обязательно реализуйте и CustomStringConvertible. Но если ваши реализации для description и debugDescription идентичны, вы можете выбрать одну и опустить другую.
Кстати, Array всегда выводит отладочное описание своих элементов, даже когда вызывается через String(describing:). Причина в том, что описание массива никогда не должно быть представлено пользователю, следовательно, вывод description может быть оптимизирован для отладки. А массив пустых строк будет выглядеть неправильно без заключительных кавычек, которые String.description опускает.
Учитывая, что приведение к CustomStringConvertible подразумевает, что тип имеет красивый вывод, вы можете быть искушены написать что-то вроде следующей обобщенной функции:
func doSomethingAttractive<T: CustomStringConvertible>(with value: T) {
// Выводит что-то, включая значение, с уверенностью, что
// оно будет выведено разумно.
}
Но вы не должны использовать CustomStringConvertible таким образом. Вместо того чтобы проверять типы, чтобы установить, имеют ли они свойство description, вы должны использовать String(describing:) независимо и смириться с некрасивым выводом, если тип не соответствует протоколу. Это никогда не приведет к ошибке для любого типа. И это хорошая причина реализовать CustomStringConvertible, когда вы пишете что-то большее, чем очень простой тип. Это займет всего несколько строк.
LosslessStringConvertible Link to heading
Последним из протоколов StringConvertible является LosslessStringConvertible. Он основывается на CustomStringConvertible и добавляет инициализатор для преобразования из строки обратно в ваш пользовательский тип:
public protocol LosslessStringConvertible: CustomStringConvertible {
/// Создает экземпляр соответствующего типа из строкового
/// представления.
init ?( _ description: String)
}
Как следует из названия, строковое представление должно быть без потерь, сохраняя значение оригинала, что позволяет выполнять обратные преобразования без потери информации. В стандартной библиотеке числовые типы, строковые типы и Bool соответствуют LosslessStringConvertible. Протокол используется довольно редко. Если вам нужно сериализовать более чем одно значение в строку, система Codable в Swift более гибкая. См. главу “Кодирование и декодирование” для получения дополнительной информации.
Потоки текстового вывода Link to heading
Функции print и dump в стандартной библиотеке записывают текст в стандартный вывод.
Версии этих функций по умолчанию перенаправляют вызовы к перегрузкам с именами print(_:to:) и dump(_:to:). Аргумент to: — это целевой вывод; он может быть любого типа, который соответствует протоколу TextOutputStream:
public func print<Target: TextOutputStream>(
_ items: Any..., separator: String = " ",
terminator: String = "\n", to output: inout Target)
Стандартная библиотека поддерживает внутренний поток текстового вывода, который записывает все, что к нему передается, в стандартный вывод. Что еще можно использовать в качестве вывода? На самом деле, String — это единственный релевантный тип в стандартной библиотеке, который является потоком вывода (также Substring и DefaultStringInterpolation являются потоками вывода, но обычно вы не будете записывать в них):
var s = ""
let numbers = [1, 2, 3, 4]
print(numbers, to: &s)
s // [1, 2, 3, 4]
Это полезно, если вы хотите перенаправить вывод функций print и dump в строку. Кстати, стандартная библиотека также использует потоки вывода, чтобы позволить Xcode захватывать все логи stdout. Обратите внимание на это объявление глобальной переменной в стандартной библиотеке:
public var _playgroundPrintHook: ((String) -> Void)?
Если это значение не равно nil, print будет использовать специальный поток вывода, который перенаправляет все, что печатается, как в стандартный вывод, так и в эту функцию. Объявление даже является публичным, так что вы можете использовать это для своих собственных шалостей:
var printCapture = ""
_playgroundPrintHook = { text in
printCapture += text
}
print("Это должно выводиться только в stdout")
printCapture // Это должно выводиться только в stdout
Но не полагайтесь на это! Это совершенно не задокументировано, и мы не знаем, какая функциональность в Xcode сломается, когда вы переназначите это.
Мы также можем создать свои собственные потоки вывода. Протокол имеет только одно требование: метод write, который принимает строку и записывает ее в поток. Например, этот поток вывода буферизует записи в массив:
struct ArrayStream: TextOutputStream {
var buffer: [String] = []
mutating func write(_ string: String) {
buffer.append(string)
}
}
var stream = ArrayStream()
print("Привет", to: &stream)
print("Мир", to: &stream)
stream.buffer // ["", "Привет", "\n", "", "Мир", "\n"]
Документация явно позволяет функциям, которые записывают в поток вывода, вызывать write(_:) несколько раз за одну операцию записи. Вот почему массив буфера в приведенном выше примере содержит отдельные элементы для переносов строк и даже некоторые пустые строки. Это деталь реализации функции print, которая может измениться в будущих релизах.
Еще одна возможность — расширить Data, чтобы она принимала поток, записывая его как выходные данные в кодировке UTF-8:
extension Data: TextOutputStream {
mutating public func write(_ string: String) {
self.append(contentsOf: string.utf8)
}
}
var utf8Data = Data()
var string = "café"
utf8Data.write(string)
Array(utf8Data) // [99, 97, 102, 195, 169]
Источник потока вывода может быть любого типа, который соответствует протоколу TextOutputStreamable. Этот протокол требует обобщенного метода write(to:), который принимает любой тип, соответствующий TextOutputStream, и записывает self в него. В стандартной библиотеке String, Substring, Character и Unicode.Scalar соответствуют TextOutputStreamable, но вы также можете добавить соответствие для своих собственных типов.
Как мы видели, внутри print использует некоторую обертку, соответствующую TextOutputStream, для стандартного вывода. Вы можете написать что-то подобное для стандартного вывода ошибок, например, так:
struct StdErr: TextOutputStream {
mutating func write(_ string: String) {
guard !string.isEmpty else { return }
// Строки могут быть переданы напрямую в C-функции, которые принимают
// const char* — смотрите главу о совместимости для получения дополнительной информации.
fputs(string, stderr)
}
}
var standarderror = StdErr()
print("упс!", to: &standarderror)
Резюме Link to heading
Строки в Swift очень отличаются от своих аналогов в почти всех других популярных языках программирования. Когда вы привыкли к тому, что строки эффективно представляют собой массивы единиц кода, вам потребуется время, чтобы переключить свое мышление на подход Swift, который приоритизирует корректность Unicode над простотой.
В конечном итоге, мы считаем, что Swift делает правильный выбор. Текст в формате Unicode гораздо более сложен, чем то, что предполагают другие языки. В долгосрочной перспективе экономия времени от избежания ошибок, которые вы иначе написали бы, вероятно, перевесит время, потраченное на переобучение индексированию целых чисел.
Мы так привыкли к случайному доступу к “символам”, что можем не осознавать, как редко эта функция действительно нужна в коде обработки строк. Мы надеемся, что примеры в этой главе убедят вас в том, что простое последовательное прохождение по строке вполне подходит для большинства распространенных операций. Принуждение вас быть явным в том, с каким представлением строки вы хотите работать — кластерами графем, скалярными значениями Unicode, единицами кода UTF-8 или единицами кода UTF-16 — является еще одной мерой безопасности; читатели вашего кода будут вам за это благодарны.
Когда Крис Латтнер изложил цели реализации строк в Swift в июле 2016 года, он закончил следующим образом:
Наша цель — быть лучше в обработке строк, чем Perl!
Swift 5.5 все еще не достиг этой цели — слишком много желаемых функций отсутствует, включая перенос большего количества API строк из Foundation в стандартную библиотеку, нативную поддержку языков для регулярных выражений и API для форматирования и разбора строк. Но хорошая новость заключается в том, что команда Swift выразила интерес к решению всех этих вопросов в будущем.
Generics Link to heading
9 Link to heading
Обобщенное программирование — это техника написания повторно используемого кода при сохранении типобезопасности. Например, стандартная библиотека использует обобщенное программирование, чтобы метод сортировки принимал пользовательскую функцию компаратора, при этом гарантируя, что типы параметров компаратора соответствуют типу элементов последовательности, которая сортируется. Аналогично, массив является обобщенным по отношению к типу элементов, которые он содержит, чтобы предоставить типобезопасный API для доступа к содержимому массива и его изменения.
Когда мы говорим об обобщенном программировании в Swift, мы обычно имеем в виду программирование с использованием обобщений (обозначаемых угловыми скобками в синтаксисе Swift, например, Array). Тем не менее, полезно увидеть более широкий контекст, в котором существуют обобщения. Обобщения являются формой полиморфизма. Полиморфизм означает использование одного интерфейса или имени, которое работает с несколькими типами.
Существует как минимум четыре различных концепции, которые можно сгруппировать под полиморфным программированием:
→ Мы можем определить несколько функций с одинаковым именем, но с разными типами. Например, в главе о функциях мы определили три разные функции с именем sortDescriptor, все из которых имели разные типы параметров. Это называется перегрузкой, или, более технически, адхок-полиморфизмом.
→ Когда функция или метод ожидает класс C, мы также можем передать подкласс C. Это называется полиморфизмом подтипов.
→ Когда функция имеет обобщенный параметр (в угловых скобках), мы называем ее обобщенной функцией (и аналогично для обобщенных типов). Это называется параметрическим полиморфизмом. Обобщенные параметры также называются обобщениями.
→ Мы можем определить протоколы и заставить несколько типов соответствовать им. Это еще одна (более структурированная) форма адхок-полиморфизма, которую мы обсудим в главе о протоколах.
Какой концепт используется для решения конкретной проблемы, часто является вопросом вкуса. В этой главе мы поговорим о третьей технике — параметрическом полиморфизме. Обобщения часто используются вместе с протоколами для указания ограничений на обобщенные параметры. Мы увидим примеры этого в главе о протоколах, но эта глава сосредоточена только на обобщениях.
Обобщенные типы Link to heading
Самая обобщенная функция, которую мы можем написать, — это функция идентичности, т.е. функция, которая возвращает свой ввод без изменений:
func identity<A>( _ value: A) -> A {
return value
}
Функция идентичности имеет один обобщенный параметр: для любого A, который мы выбираем, у нее тип (A) -> A. Однако функция идентичности имеет неограниченное количество конкретных типов (типов без обобщенных параметров, таких как Int, Bool и String). Например, если мы выберем A как Int, конкретный тип будет (Int) -> Int; если мы выберем A как (String -> Bool), тогда ее тип будет ((String) -> Bool) -> (String) -> Bool; и так далее.
Функции и методы — не единственные обобщенные типы. Мы также можем иметь обобщенные структуры, классы и перечисления. Например, вот определение Optional:
enum Optional<Wrapped> {
case none
case some(Wrapped)
}
Мы говорим, что Optional — это обобщенный тип. Когда мы выбираем значение для Wrapped, мы получаем конкретный тип. Например, Optional или Optional — это оба конкретных типа. Мы можем рассматривать Optional (без угловых скобок) как конструктор типов: заданный конкретный тип (например, Int) он конструирует другой конкретный тип (например, Optional).
Когда мы смотрим на стандартную библиотеку, мы видим, что там много конкретных типов, но также много обобщенных типов (например: Array, Dictionary и Result). Тип Array имеет один обобщенный параметр, Element. Это означает, что мы можем выбрать любой конкретный тип и использовать его для создания массива. Мы также можем создать наши собственные обобщенные типы. Например, вот перечисление, которое описывает бинарное дерево со значениями в узлах:
enum BinaryTree<Element> {
case leaf
indirect case node(Element, l: BinaryTree<Element>, r: BinaryTree<Element>)
}
BinaryTree — это обобщенный тип с одним обобщенным параметром, Element. Чтобы создать конкретный тип, нам нужно выбрать конкретный тип для Element. Например, мы можем выбрать Int:
let tree: BinaryTree<Int> = .node(5, l: .leaf, r: .leaf)
Когда мы хотим превратить обобщенный тип в конкретный тип, нам нужно выбрать ровно один конкретный тип для каждого обобщенного параметра. Вы, возможно, уже знакомы с этим ограничением при создании массива. Например, когда мы создаем пустой массив, мы обязаны указать явный тип; в противном случае Swift не знает, какой конкретный тип использовать для его элементов:
// Требуется аннотация типа.
var emptyArray: [String] = []
Вы можете помещать значения с разными конкретными типами в массив, пока тип элемента массива является общим суперклассом для всех членов. Для массива объектов компилятор автоматически выводит тип элемента как их наиболее специфичный общий суперкласс:
// Тип выводится как [UIView].
let subviews = [UILabel(), UISwitch()]
В других ситуациях Swift заставляет вас явно признать, что вы имели в виду создать массив Any:
// Требуется аннотация типа.
let multipleTypes: [Any] = [1, "foo", true]
Мы обсудим Any в разделе о обобщенных функциях.
Расширение Обобщенных Типов Link to heading
Обобщенный параметр Element доступен в любом месте в области видимости BinaryTree. Например, мы можем свободно использовать Element, как если бы это был конкретный тип, при написании расширения для BinaryTree. Давайте добавим удобный инициализатор, который использует Element в качестве своего параметра:
extension BinaryTree {
init(_ value: Element) {
self = .node(value, l: .leaf, r: .leaf)
}
}
А вот вычисляемое свойство, которое рекурсивно собирает все значения в дереве и возвращает их в виде массива:
extension BinaryTree {
var values: [Element] {
switch self {
case .leaf:
return []
case let .node(el, left, right):
return left.values + [el] + right.values
}
}
}
Когда мы вызываем values на BinaryTree<Int>, мы получаем массив целых чисел:
tree.values // [5]
Мы также можем добавить обобщенные методы. Например, мы можем добавить map, который имеет дополнительный обобщенный параметр T для типа возвращаемого значения функции преобразования и типа элемента преобразованного дерева. Поскольку мы определяем map в расширении BinaryTree, мы все еще можем использовать обобщенный параметр Element:
extension BinaryTree {
func map<T>(_ transform: (Element) -> T) -> BinaryTree<T> {
switch self {
case .leaf:
return .leaf
case let .node(el, left, right):
return .node(transform(el),
l: left.map(transform),
r: right.map(transform))
}
}
}
Поскольку ни Element, ни T не ограничены протоколом, вызывающий может выбрать любой конкретный тип, который он хочет для них. Мы даже можем выбрать один и тот же конкретный тип для обоих обобщенных параметров:
let incremented: BinaryTree<Int> = tree.map { $0 + 1 }
// node(6, l: BinaryTree<Swift.Int>.leaf, r: BinaryTree<Swift.Int>.leaf)
Но мы также можем выбрать разные типы. В этом примере Element — это Int, но T — это String:
let stringValues: [String] = tree.map { "\($0)" }.values // ["5"]
В Swift многие коллекции являются обобщенными (например: Array, Set и Dictionary). Однако обобщения полезны не только для коллекций; они также используются во всей стандартной библиотеке Swift. Например:
Optionalиспользует обобщенный параметр для абстракции над своим обернутым типом.Resultимеет два обобщенных параметра — один представляет успешное значение, а другой представляет ошибку.Unsafe[Mutable]Pointerявляется обобщенным по типу памяти, на которую он указывает.KeyPathявляется обобщенным как по своему корневому типу, так и по типу результирующего значения.Rangeявляется обобщенным по своему элементному типу (называемомуBound).
Generics против Any Link to heading
Дженерики и Any могут использоваться для схожих целей, но ведут себя совершенно по-разному. В языках без дженериков часто используется Any для достижения той же цели, но с меньшей безопасностью типов. Это обычно означает использование программирования во время выполнения, такого как интроспекция и динамические приведения типов, для извлечения конкретного типа из переменной типа Any. Дженерики могут быть использованы для решения многих из тех же проблем, но с дополнительным преимуществом проверки на этапе компиляции, что позволяет избежать накладных расходов во время выполнения.
При чтении кода дженерики могут помочь нам понять, что делает метод или функция. Например, рассмотрим тип функции reduce для массивов (в этом примере мы проигнорируем тот факт, что reduce на самом деле определен в протоколе Sequence):
extension Array {
func reduce<Result>( _ initial: Result, _ combine: (Result, Element) -> Result) -> Result
}
Не заглядывая в реализацию, мы можем многое сказать только по сигнатуре типов (при условии, что у reduce есть разумная реализация): → Прежде всего, мы знаем, что reduce является дженериком по типу Result, который также является возвращаемым типом. (Result — это имя дженерик-параметра. Не путайте его с enum Result из стандартной библиотеки.) → Смотрим на входные параметры, мы видим, что функция принимает значение типа Result, вместе с методом, который комбинирует Result и Element в новый Result. → Поскольку возвращаемый тип — это Result, возвращаемое значение reduce должно быть либо initial, либо возвращаемым значением от вызова combine. Мы знаем это, потому что метод не имеет возможности конструировать произвольные значения типа Result. → Если массив пуст, у нас нет значения Element, поэтому единственное, что может быть возвращено — это initial. → Если массив не пуст, тип оставляет некоторую свободу реализации: метод может вернуть initial, не обращая внимания на элементы (хотя это будет граничить с неразумной реализацией), или метод может вызвать combine либо с одним из элементов (например, с первым или последним элементом), либо последовательно со всеми элементами.
Обратите внимание, что существует бесконечное количество других возможных реализаций. Например, реализация может вызывать combine только для некоторых элементов. Она может использовать интроспекцию типов во время выполнения, изменять какое-то глобальное состояние или делать сетевой вызов. Однако ни одно из этих действий не будет квалифицироваться как разумная реализация, и поскольку reduce определен в стандартной библиотеке, мы можем быть уверены, что у него есть разумная реализация.
Теперь рассмотрим тот же метод, определенный с использованием Any:
extension Array {
func reduce( _ initial: Any , _ combine: ( Any , Any ) -> Any ) -> Any
}
Эта сигнатура типов несет гораздо меньше информации, даже если мы рассматриваем только разумные реализации. Просто глядя на тип, мы не можем действительно понять связь между первым параметром и возвращаемым значением. Точно так же неясно, в каком порядке аргументы передаются в комбинирующую функцию. Даже неясно, что комбинирующая функция используется для объединения результата и элемента.
В некотором смысле, чем более универсальна функция или метод, тем меньше они могут сделать. Вспомните функцию идентичности: она настолько универсальна, что у нее есть только одна разумная реализация:
func identity<A>( _ value: A) -> A {
return value
}
На нашем опыте дженерики являются отличной помощью при чтении кода. Более конкретно, когда мы видим очень универсальную функцию или метод, такие как reduce или map, нам не нужно догадываться, что они делают: количество возможных реализаций ограничено типом.
Проектирование с использованием обобщений Link to heading
Обобщения могут быть очень полезны при проектировании вашей программы, так как они помогают выделить общую функциональность и сократить количество шаблонного кода. В этом разделе мы рефакторим не обобщенный фрагмент сетевого кода, выделяя общую функциональность с помощью обобщений. Мы напишем расширение для URLSession, loadUser, которое загружает профиль текущего пользователя из веб-сервиса и парсит его в тип User. Сначала мы конструируем URL и начинаем сетевой запрос. Затем мы декодируем загруженные данные, используя инфраструктуру Codable (о которой мы поговорим в главе о кодировании и декодировании):
extension URLSession {
func loadUser() async throws -> User {
let userURL = webserviceURL.appendingPathComponent("/profile")
let (data, _) = try await data(from: userURL)
return try JSONDecoder().decode(User.self, from: data)
}
}
Если бы мы хотели повторно использовать ту же функцию для загрузки другого типа (например, BlogPost), реализация была бы почти такой же. Мы бы дублировали код и изменили три вещи: тип возвращаемого значения, URL и выражение User.self в вызове JSONDecoder.decode(_:from:). Но есть лучший способ — заменив конкретный тип User на обобщенный параметр, мы можем сделать наш метод load обобщенным, оставив большую часть реализации прежней. В то же время, вместо того чтобы парсить JSON напрямую, мы добавляем параметр к load: функцию парсинга, которая знает, как парсить данные, возвращаемые веб-сервисом, в значение типа A (как мы увидим чуть позже, это дает нам много гибкости):
extension URLSession {
func load<A>(url: URL, parse: (Data) throws -> A) async throws -> A {
let (data, _) = try await data(from: url)
return try parse(data)
}
}
Новая функция load принимает URL и функцию парсинга в качестве параметров, поскольку эти входные данные теперь зависят от конкретной конечной точки для загрузки. Этот рефакторинг следует той же стратегии, которую мы видели для map и других методов стандартной библиотеки в главе о встроенных коллекциях:
- Определение общей схемы в задаче (загрузка данных с HTTP URL и парсинг ответа).
- Извлечение шаблонного кода, выполняющего эту задачу, в обобщенный метод.
- Позволение клиентам внедрять изменяющиеся элементы из вызова в вызов (конкретный URL для загрузки и способ парсинга ответа) через обобщенные параметры и аргументы функции.
Теперь мы можем вызывать load с двумя разными конечными точками и почти без дублирования кода:
let profileURL = webserviceURL.appendingPathComponent("profile")
let user = try await URLSession.shared.load(url: profileURL, parse: {
try JSONDecoder().decode(User.self, from: $0)
})
print(user)
let postURL = webserviceURL.appendingPathComponent("blog")
let post = try await URLSession.shared.load(url: postURL, parse: {
try JSONDecoder().decode(BlogPost.self, from: $0)
})
print(post)
Поскольку URL и функция парсинга для декодирования данных, возвращаемых с этого URL, естественно связаны, имеет смысл сгруппировать их вместе в обобщенной структуре Resource:
struct Resource<A> {
let url: URL
let parse: (Data) throws -> A
}
Вот те же самые две конечные точки, определенные как Resource:
let profile = Resource<User>(url: profileURL, parse: {
try JSONDecoder().decode(User.self, from: $0)
})
let post = Resource<BlogPost>(url: postURL, parse: {
try JSONDecoder().decode(BlogPost.self, from: $0)
})
Поскольку функция парсинга Resource не знает ничего о декодировании JSON, мы можем использовать Resource для представления других типов ресурсов. Например, мы можем создать ресурсы для загрузки изображений или XML-данных. С другой стороны, добавленная гибкость означает, что все JSON-ресурсы должны повторять один и тот же код декодирования JSON в своих функциях парсинга. Чтобы избежать этого дублирования в каждом JSON-ресурсе, мы создаем удобный инициализатор, который зависит от того, что A является Decodable. Инициализатор использует обобщенный параметр A, чтобы декодировать правильный тип:
extension Resource where A: Decodable {
init(json url: URL) {
self.url = url
self.parse = { data in
try JSONDecoder().decode(A.self, from: data)
}
}
}
Это позволяет нам определять те же ресурсы гораздо короче:
let profile = Resource<User>(json: profileURL)
let blogPost = Resource<BlogPost>(json: postURL)
Наконец, мы пишем версию метода load, которая принимает значение Resource:
extension URLSession {
func load<A>(_: Resource<A>) async throws -> A {
let (data, _) = try await data(from: r.url)
return try r.parse(data)
}
}
Теперь мы можем вызывать load с нашим ресурсом профиля:
let user = try await URLSession.shared.load(profile)
print(user)
Как приятный побочный эффект создания обобщенного типа Resource, мы сделали код более удобным для тестирования, потому что логика, которую мы хотели бы протестировать (парсинг), теперь полностью синхронна и не имеет зависимостей. Метод load в URLSession по-прежнему трудно тестировать, потому что он асинхронен, и его зависимость от URLSession усложняет тестовую среду. Но эта сложность теперь изолирована в одном методе, а не в нескольких местах.
Generics имеют статическую диспетчеризацию Link to heading
Swift поддерживает перегрузку функций, т.е. может быть несколько функций с одинаковым именем, но с разными типами аргументов и/или возвращаемыми типами. Процесс принятия решения компилятора о том, какую функцию вызывать, называется разрешением перегрузки. Для каждого места вызова компилятор следует набору правил, которые сводятся к «выбору наиболее специфичной перегрузки для заданных типов входных данных и результата». Разрешение перегрузки всегда происходит статически на этапе компиляции; динамическая информация о типах во время выполнения не играет в этом никакой роли.
Вот пример простой функции форматирования с двумя перегрузками — обобщенной для всех A и специфической для Int:
// Обобщенная версия для всех значений.
func format<A>(_ value: A) -> String {
String(describing: value)
}
// Перегрузка с пользовательским поведением для Int.
func format(_ value: Int) -> String {
"+\(value)+"
}
Неудивительно, что когда мы вызываем format с Int, Swift вызывает специфическую версию. Для любого другого типа аргумента он должен выбрать обобщенную версию, так как это единственная с подходящим типом:
format("Hello") // Hello
format(42) // +42+
Теперь давайте добавим еще одну обобщенную функцию, которая вызывает format как часть своей реализации:
func process<B>(_ input: B) -> String {
let formatted = format(input)
return "–\(formatted)–"
}
Как и ожидалось, вызов process со строкой приводит к вызову нашей обобщенной перегрузки format. Но когда мы вызываем process с Int, он также вызывает менее специфическую перегрузку format:
process("Hello") // –Hello–
//
process(42) // –42–
Многие люди находят это удивительным. Разве компилятор не должен иметь всю информацию, чтобы увидеть, что существует более специфичная функция для аргументов типа Int? Это может быть так в этом примере, где вызывающая и вызываемая функции компилируются вместе как часть одного модуля, но это не всегда верно: если бы вызов process(42) находился в другом модуле, чем реализация process, функция уже была бы скомпилирована, и, поскольку разрешение перегрузки всегда происходит на этапе компиляции, решение о том, какую перегрузку вызывать, уже было бы принято.
Swift использует только информацию, которая доступна локально в месте вызова, для разрешения перегрузки, и это включает информацию о обобщенных параметрах и их ограничениях. Компилятор никогда не выберет перегрузку на основе знаний вне локальной области видимости. Другой способ взглянуть на это — то, что семантически существует только одна версия каждой обобщенной функции или типа, и эта версия должна быть способна обрабатывать все допустимые типы аргументов. Это резко отличается от шаблонов в C++, которые компилируются в отдельные инстанциации для каждого конкретного типа. Мы говорим «семантически», потому что Swift действительно компилирует специализированные версии обобщенных объявлений как оптимизацию производительности, но это деталь реализации и не меняет поведение кода.
Итак, какие у нас есть варианты, если мы хотим динамически диспетчеризовать вызов к format на основе динамического типа аргумента во время выполнения? Одной из альтернатив является ручное восстановление конкретного типа с помощью динамического приведения as? внутри вызывающей функции:
func process2<B>(_ input: B) -> String {
let formatted: String
if let int = input as? Int {
formatted = format(int)
} else {
formatted = format(input)
}
return "–\(formatted)–"
}
Мы по сути реализовали собственный механизм динамической диспетчеризации здесь. Это работает для нашего примера, но это громоздко и требует ручного обслуживания по мере добавления новых перегрузок. Лучший подход — сделать функцию format требованием протокола и ограничить обобщенные параметры этим протоколом. Это решение будет использовать динамическую диспетчеризацию, потому что требования протокола диспетчеризуются динамически. Мы скажем больше об этом в следующей главе, «Протоколы».
Как работают обобщения Link to heading
Как обобщения реализованы в компиляторе? Чтобы ответить на этот вопрос, давайте подробнее рассмотрим функцию min из стандартной библиотеки (мы взяли этот пример из сессии “Оптимизация производительности Swift” на Всемирной конференции разработчиков Apple 2015 года):
func min<T: Comparable>( _ x: T, _ y: T) -> T {
return y < x ? y : x
}
Единственные ограничения для аргументов и возвращаемого значения функции min заключаются в том, что все три должны иметь один и тот же тип T, и что T должен соответствовать протоколу Comparable. Кроме того, T может быть чем угодно — Int, Float, String или даже типом, о котором компилятор ничего не знает на этапе компиляции, потому что он определен в другом модуле. Это означает, что компилятору не хватает трех основных частей информации, необходимых для генерации кода для функции:
- Размер значений типа
T(включая аргументы и возвращаемое значение). - Как копировать и уничтожать значения типа
T(например, нужно ли учитывать счетчик ссылок). - Адрес конкретной перегрузки функции
<, которую нужно вызвать.
Swift решает эти проблемы, вводя уровень косвенности для обобщенного кода:
- Аргументы функции, возвращаемые значения и переменные неизвестного размера передаются по указателю.
- Для каждого параметра типа
Tкомпилятор передает метаданные типа дляTв функцию. Среди прочего, запись метаданных типа содержит таблицу свидетельств значений (VWT). VWT предоставляет функции для основных операций над значениями типаT, таких как копирование, перемещение или уничтожение значения. Это могут быть простые операции или копирование памяти для тривиальных типов значений, таких какInt, в то время как ссылочные типы включают свою логику учета ссылок. VWT также записывает макет памяти (размер и выравнивание) типа. - Для каждого ограничения на
Tкомпилятор передает таблицу свидетельств протокола (PWT) в функцию. PWT — это таблица виртуальных функций для требований протокола. Она используется для динамической диспетчеризации вызовов функций к правильным реализациям во время выполнения.
В нашем примере PWT для “T является Comparable” предоставляет правильную функцию <. В псевдокоде инструкции, которые компилятор генерирует для функции min, выглядят примерно так:
void func min( _ x: OpaquePointer, _ y: OpaquePointer,
returnValue: OpaquePointer, // память, выделенная вызывающим
meta_T: TypeMetadata, T_is_Comparable: VTable)
{
let result = T_is_Comparable.lessThan(y, x, meta_T, T_is_Comparable)
if result {
metadata_T.vwt.copyInit(returnValue, from: y, meta_T)
} else {
metadata_T.vwt.copyInit(returnValue, from: x, meta_T)
}
}
Поскольку макет памяти T неизвестен на этапе компиляции, все значения передаются по указателю, включая возвращаемое значение. Компилятор проходит через таблицу свидетельств протокола, чтобы вызвать y < x, а затем через таблицу свидетельств значений, чтобы скопировать y или x в память для возвращаемого значения.
Таблицы свидетельств протоколов обеспечивают сопоставление между протоколами, к которым соответствует обобщенный параметр (это статически известно компилятору через ограничения), и функциями, которые реализуют протокол для конкретного типа (они известны только во время выполнения). На самом деле, единственный способ вызвать методы протокола — это через таблицу свидетельств. Мы не могли бы объявить функцию min с неконтролируемым параметром <T> и затем ожидать, что она будет работать с любым типом, у которого есть реализация для <, независимо от соответствия Comparable. Компилятор не разрешил бы это, потому что не было бы таблицы свидетельств, чтобы найти правильную реализацию <. Вот почему обобщения так тесно связаны с протоколами — вы не можете сделать много с неконтролируемыми обобщениями, кроме как писать контейнерные типы, такие как Array<Element> или Optional<Wrapped>. Мы вернемся к этой теме в следующей главе в обсуждении свидетельств протоколов.
Использование указателей для передачи обобщенных значений в приведенном выше псевдокоде может создать у вас впечатление, что Swift использует упаковку для представления значений неизвестного размера, но это не так. Указатели существуют, потому что компилятор не может выделить регистры ЦП для значений переменного размера, но значения, на которые указывают эти указатели, все еще могут находиться непосредственно в стеке без дополнительных накладных расходов. Это может быть гораздо более эффективно, чем упаковка, особенно когда дело касается коллекций: массив обобщенных значений [T] (когда T == Int) имеет точно такой же макет памяти во время выполнения, как и конкретный массив [Int]. Обобщенный код, взаимодействующий с этим массивом, использует косвенность для манипуляции элементами, но сами элементы плотно и непрерывно упакованы в памяти. Этот дизайн оптимизирует кэширование для наилучшей возможной локальности кэша в кэшах ЦП.
Если вы хотите узнать больше о том, как работает система обобщений, разработчики компилятора Swift Слава Пестов и Джон МаКолл провели доклад на эту тему на встрече разработчиков LLVM в 2017 году.
Generic Specialization Link to heading
По сравнению с негенерическим кодом, модель компиляции Swift для обобщений явно имеет стоимость производительности во время выполнения, вызванную косвенной адресацией, через которую должен проходить код. Это, вероятно, незначительно, если рассматривать отдельный вызов функции, но накапливается, когда обобщения так же повсеместны, как в Swift. Стандартная библиотека использует обобщения повсюду, включая очень распространенные операции, которые должны выполняться как можно быстрее, такие как сравнение значений.
К счастью, компилятор Swift может использовать оптимизацию, называемую специализированием обобщений — или мономорфизацией — которая, во многих случаях, полностью устраняет накладные расходы. Специализация означает, что компилятор клонирует обобщенный тип или функцию, такую как min<T>, для конкретного аргумента типа, такого как Int. Эта специализированная функция затем может быть оптимизирована специально для Int, устраняя всю косвенную адресацию. Таким образом, специализированная версия min<T> для Int будет выглядеть так:
func min(_ x: Int, _ y: Int) -> Int {
return y < x ? y : x
}
Это именно та реализация, которую вы бы написали вручную для конкретной, негенерической функции min. Специализация обобщений не только экономит стоимость виртуальной диспетчеризации, но и позволяет проводить дальнейшие оптимизации, такие как инлайнинг, для которых косвенная адресация в противном случае была бы барьером.
Когда вы компилируете свой код с включенными оптимизациями (swiftc -O в командной строке или swift package build -c release с SwiftPM), оптимизатор создаст специализированные версии ваших обобщенных типов и функций для каждой конкретной комбинации типов, которую он может увидеть. Если ваш код вызывает min с аргументами Int и Float, эти две специализации окажутся в бинарном файле (и компилятор будет генерировать вызовы к ним в местах вызова). Неспециализированная версия min<T> также останется доступной для любых других вызовов, где входной тип не может быть определен во время компиляции.
Swift на самом деле не дает никаких гарантий относительно того, какие специализации компилятор будет синтезировать. В Swift 5.5 правила просты: специализируйте для всех видимых мест вызова, когда оптимизации включены; в противном случае не специализируйте ничего. В будущем оптимизатор может использовать эвристики, которые учитывают, как часто функция вызывается с определенным набором входных типов, чтобы сделать более сбалансированный компромисс между производительностью во время выполнения, размером кода и временем компиляции.
Проблема со специализацией заключается в том, что она работает только тогда, когда оптимизатор может видеть полное определение обобщенного типа или функции в момент компиляции места вызова. Это всегда так, если и вызывающая, и вызываемая функции находятся в одном файле. Если они не находятся в одном файле, у вас есть два варианта, чтобы помочь оптимизатору:
Включите оптимизацию всего модуля. Это режим компиляции, в котором все файлы в текущем модуле оптимизируются вместе. Кроме специализации обобщений, оптимизация всего модуля позволяет проводить другие важные оптимизации. Например, оптимизатор распознает, когда внутренний класс не имеет подклассов в пределах всего модуля. Поскольку модификатор
internalгарантирует, что класс не виден за пределами модуля, это означает, что компилятор может заменить динамическую диспетчеризацию на статическую диспетчеризацию для всех методов этого класса.Для клиентов в других модулях сделайте ваши обобщенные функции @inlinable. Этот атрибут экспортирует тело функции как часть интерфейса модуля, делая его доступным для оптимизатора, когда он ссылается на него из других модулей. В этом случае модуль, содержащий обобщенную функцию, уже скомпилирован, когда строится место вызова, но оптимизатор может сгенерировать специализированную версию функции в вызываемом модуле.
Вот наша функция min с атрибутом @inlinable:
@inlinable
func min<T: Comparable>(_ x: T, _ y: T) -> T {
return y < x ? y : x
}
Стандартная библиотека, а также другие низкоуровневые пакеты, такие как SwiftNIO, Swift Collections и Swift Algorithms, активно используют @inlinable, чтобы сделать свои API специализированными (и для облегчения других оптимизаций). Экспериментальный флаг компилятора -cross-module-optimization пытается автоматизировать этот процесс, делая все публичные API модуля инлайновыми по умолчанию. Это еще не официальная функция в Swift 5.5, но есть немного недостатков в том, чтобы попробовать это для модулей, которые статически связаны в один бинарный файл. Сделать что-то инлайновым — это большое обязательство для библиотек с ABI-стабильностью, таких как стандартная библиотека, потому что это может сделать невозможным изменение реализаций или исправление ошибок. Но эти проблемы не существуют для файлов, которые всегда (пере)компилируются вместе.
Модель компиляции Swift для обобщений несколько уникальна тем, что она преодолевает разрыв между языками, такими как C++ и Rust с одной стороны (которые специализируют все), и более простыми моделями обобщений, такими как Java, с другой стороны (где обобщения используются для проверки типов, но стираются через упаковку во время выполнения). Оставляя в стороне соображения производительности, подход Swift позволяет отдельную компиляцию обобщенных функций для типов и их клиентов. Это важная особенность для Apple, потому что она позволяет Apple поставлять Swift-фреймворки в бинарном виде в своих SDK.
Резюме Link to heading
В течение этой главы мы увидели, как обобщения позволяют реализовать полиморфизм — более конкретно, параметрический полиморфизм. Обобщения могут использоваться во многих местах: мы можем писать обобщенные типы, обобщенные функции и обобщенные подиндексы. Мы видели и использовали обобщения на протяжении всей книги, потому что стандартная библиотека использует их широко.
Аналогично, в нашем собственном коде мы можем использовать обобщения, чтобы абстрагировать конкретные детали и достичь четкого разделения ответственности.
Наконец, хотя неконтролируемые обобщения уже полезны для контейнеров, таких как Array и Optional, сочетание обобщений с ограничениями протоколов открывает совершенно новый уровень. Это приводит нас к нашей следующей главе, Протоколы.
Протоколы Link to heading
10 Link to heading
Когда мы работаем с обобщенными типами, мы часто хотим ограничить их обобщенные параметры. Протоколы позволяют делать именно это. Вот несколько распространенных примеров: → Вы можете использовать протокол для построения алгоритма, который зависит от того, что тип является числом (независимо от конкретного числового типа) или коллекцией. Программируя против протокола, все соответствующие типы получают новую функциональность. → Вы можете использовать протокол для абстрагирования различных «бэкендов» для вашего кода. Вы делаете это, программируя против протокола, который затем реализуется различными типами. Например, программа для рисования может использовать протокол Drawable и быть реализована с помощью рендерера SVG и рендерера CoreGraphics. Аналогично, кроссплатформенный код может использовать протокол Platform с конкретными экземплярами для Linux, macOS и iOS. → Вы можете использовать протоколы для того, чтобы сделать код тестируемым. Более конкретно, когда вы пишете код, который использует протокол, а не конкретный тип, вы можете использовать различные конкретные реализации в вашем производственном коде и в ваших тестах.
Протокол в Swift объявляет формальный набор требований. Например, протокол Equatable требует, чтобы соответствующий тип реализовывал оператор ==:
public protocol Equatable {
static func == (lhs: Self, rhs: Self) -> Bool
}
Эти требования могут состоять из методов, инициализаторов, ассоциированных типов, свойств и унаследованных протоколов. Большинство протоколов также имеют дополнительные семантические требования, которые не могут быть выражены в системе типов Swift. Например, протокол Collection ожидает, что срези будут использовать тот же индекс, что и оригинальная коллекция, для доступа к конкретному элементу. Семантические требования могут включать обещания производительности, такие как гарантия RandomAccessCollection о возможности перехода между индексами за постоянное время. Тип не должен соответствовать протоколу, если он не удовлетворяет семантике протокола. Это важно, потому что алгоритмы, написанные против протокола, полагаются на эти семантические требования.
Давайте рассмотрим несколько основных особенностей протоколов Swift. На протяжении всей главы мы будем подробно обсуждать каждую из этих особенностей.
Протоколы в Swift могут предоставлять дополнительную функциональность помимо своих требований в расширениях. Самый простой пример — Equatable: он требует реализации оператора ==, но затем добавляет оператор !=, который использует определение ==:
extension Equatable {
public static func != (lhs: Self, rhs: Self) -> Bool {
return !(lhs == rhs)
}
}
Аналогично, протокол Sequence имеет несколько требований (вам нужно предоставить способ создать итератор), но добавляет множество методов через расширения.
Только требования протокола динамически dispatch-ятся. То есть, когда вы вызываете требование на переменной, решение о том, какая конкретная функция будет вызвана, принимается во время выполнения на основе динамического типа переменной. Однако расширения протокола, которые не являются требованиями, всегда dispatch-ятся статически на основе статического типа переменной. Мы увидим, почему это различие важно в разделе «Настройка расширений протоколов» ниже.
Протоколы также могут иметь условные расширения для добавления API, которые требуют дополнительных ограничений. Например, у Sequence есть метод max(), который существует только для коллекций с типом Element, который соответствует Comparable:
extension Sequence where Element: Comparable {
/// Возвращает максимальный элемент в последовательности.
public func max() -> Element? {
return self.max(by: <)
}
}
Протоколы могут наследоваться от других протоколов. Например, Hashable указывает, что любые типы, соответствующие ему, также должны быть Equatable. Аналогично, RangeReplaceableCollection наследуется от Collection, который, в свою очередь, наследуется от Sequence. Другими словами, мы можем формировать иерархии протоколов.
Кроме того, протоколы могут быть комбинированы. Например, Codable определяется как typealias, который объединяет Encodable и Decodable. Эта комбинация называется композицией протоколов.
В некоторых случаях соответствия протоколов зависят от других соответствий. Например, массив соответствует Equatable, если и только если его тип Element соответствует Equatable. Это называется условным соответствием: соответствие Array к Equatable зависит от соответствия его элементов Equatable:
extension Array: Equatable where Element: Equatable { ... }
Протоколы могут объявлять один или несколько ассоциированных типов, т.е. заполнителей для связанных типов, которые затем используются для определения других требований к протоколу. Соответствующий тип должен указать конкретный тип для каждого ассоциированного типа. Например, протокол Sequence определяет ассоциированный тип Element, и каждый тип, который соответствует Sequence, определил, каков его тип Element. Элемент типа String — это Character; элемент типа Data — это UInt8.
Protocol Witnesses Link to heading
В этом разделе мы покажем, как бы выглядел Swift без протоколов. В свою очередь, мы надеемся, что этот пример поможет вам лучше понять, как работают протоколы. Например, предположим, что мы хотим написать метод для массива, чтобы проверить, равны ли все элементы. Без протокола Equatable нам нужно явно передать функцию сравнения:
extension Array {
func allEqual(_ compare: (Element, Element) -> Bool) -> Bool {
guard let f = first else { return true }
for el in dropFirst() {
guard compare(f, el) else { return false }
}
return true
}
}
Чтобы сделать вещи немного более формальными, мы можем создать обертку вокруг функции, чтобы обозначить ее роль как функции равенства:
struct Eq<A> {
let eq: (A, A) -> Bool
}
Теперь мы можем создавать экземпляры Eq для сравнения значений различных конкретных типов, таких как Int. Мы будем называть эти значения явными свидетелями равенства:
let eqInt: Eq<Int> = Eq { $0 == $1 }
Вот снова метод allEqual, но теперь с использованием Eq, а не простой функции. Обратите внимание, что мы используем обобщенный тип Element из массива, чтобы убедиться, что все типы совпадают:
extension Array {
func allEqual(_ compare: Eq<Element>) -> Bool {
guard let f = first else { return true }
for el in dropFirst() {
guard compare.eq(f, el) else { return false }
}
return true
}
}
Хотя тип Eq может показаться немного неясным, он отражает то, как работают протоколы “под капотом”: когда вы добавляете ограничение Equatable к обобщенному типу, свидетельство протокола передается каждый раз, когда вы создаете конкретный экземпляр этого типа. В случае Equatable это свидетельство протокола содержит оператор == для сравнения двух значений. Компилятор автоматически передает это свидетельство протокола на основе конкретного типа, который вы выбираете. Вот версия allEqual, которая использует протоколы вместо явных свидетелей:
extension Array where Element: Equatable {
func allEqual() -> Bool {
guard let f = first else { return true }
for el in dropFirst() {
guard f == el else { return false }
}
return true
}
}
Термин “свидетель” для значения или типа, который соответствует протоколу (или для метода, который удовлетворяет требованиям протокола), происходит из логики. Вы можете думать об этом так: тип Int: Equatable свидетельствует, через свое существование, о том, что он соответствует протоколу (в противном случае он не скомпилируется).
Мы также можем добавить расширение к Eq. Учитывая возможность определить, равны ли два значения, мы также можем написать метод, который проверяет два значения на неравенство:
extension Eq {
func notEqual(_ l: A, _ r: A) -> Bool {
return !eq(l, r)
}
}
Это похоже на расширение протокола: мы используем тот факт, что eq существует, и можем построить на его основе дополнительную функциональность. Написание того же расширения для протокола Equatable выглядит почти идентично. Вместо использования A мы используем Self, который в протоколах является заполнителем для соответствующего типа:
extension Equatable {
static func notEqual(_ l: Self, _ r: Self) -> Bool {
return !(l == r)
}
}
Это именно то, как стандартная библиотека реализует оператор != для типов, соответствующих Equatable.
Conditional Conformance Link to heading
Чтобы написать версию Eq для массивов, нам нужен способ сравнения двух элементов. Мы можем написать нашу функцию eqArray и передать явное свидетельство в качестве значения:
func eqArray<El>( _ eqElement: Eq<El>) -> Eq<[El]> {
return Eq { arr1, arr2 in
guard arr1.count == arr2.count else { return false }
for (l, r) in zip(arr1, arr2) {
guard eqElement.eq(l, r) else { return false }
}
return true
}
}
Снова, это напрямую соответствует условному соответствию в Swift. Например, вот как стандартная библиотека делает соответствие Array протоколу Equatable:
extension Array: Equatable where Element: Equatable {
static func ==(lhs: [Element], rhs: [Element]) -> Bool {
// ...
}
}
Добавление ограничения, что Element должен соответствовать Equatable, эквивалентно добавлению параметра eqElement в функцию eqArray выше. Внутри расширения мы теперь можем использовать оператор == для сравнения двух элементов. Большое отличие заключается в том, что с протоколами свидетельство протокола передается автоматически.
НаследованиеПротоколов Link to heading
Swift также поддерживает наследование протоколов. Например, протокол Comparable требует, чтобы любые соответствующие типы также соответствовали протоколу Equatable. Это также называется уточнением.
Comparable уточняет Equatable:
public protocol Comparable: Equatable {
static func < (lhs: Self, rhs: Self) -> Bool
// ...
}
В нашей воображаемой версии Swift без протоколов мы также можем выразить эту концепцию, создав явный тип свидетеля для Comparable и включив в него Equatable, вместе с функцией lessThan:
struct Comp<A> {
let equatable: Eq<A>
let lessThan: (A, A) -> Bool
}
Снова, это похоже на то, как работают свидетели протоколов, когда протокол наследуется от других протоколов. В расширении Comp мы теперь можем использовать как Eq, так и lessThan:
extension Comp {
func greaterThanOrEqual(_ l: A, _ r: A) -> Bool {
return lessThan(r, l) || equatable.eq(l, r)
}
}
Концепция передачи явных свидетелей является полезной умственной моделью для понимания того, что компилятор делает внутренне. Мы находим это особенно полезным, когда застреваем, пытаясь решить проблему с протоколами.
Однако имейте в виду, что два подхода не являются полностью эквивалентными. Хотя мы можем создать неограниченное количество явных значений свидетелей с одним и тем же типом, тип может соответствовать протоколу не более одного раза. И в отличие от явного свидетеля, эти соответствия передаются автоматически.
Если бы было разрешено несколько соответствий для типа, компилятору пришлось бы придумать способ выбрать наиболее подходящее соответствие. В сочетании с такими функциями, как условное соответствие, это быстро усложняется. Чтобы избежать этой сложности, Swift не позволяет иметь несколько соответствий.
Проектирование с использованием протоколов Link to heading
В этом разделе мы рассмотрим пример протокола рисования, который реализован двумя конкретными типами: мы можем отрисовать рисунок либо в формате SVG, либо в графическом контексте фреймворка CoreGraphics от Apple. Давайте начнем с определения протокола с требованиями для рисования эллипса и прямоугольника:
protocol DrawingContext {
mutating func addEllipse(rect: CGRect, fill: UIColor)
mutating func addRectangle(rect: CGRect, fill: UIColor)
}
Соответствие CGContext является простым; мы устанавливаем цвет заливки, а затем заполняем указанную форму:
extension CGContext: DrawingContext {
func addEllipse(rect: CGRect, fill fillColor: UIColor) {
setFillColor(fillColor.cgColor)
fillEllipse(in: rect)
}
func addRectangle(rect: CGRect, fill fillColor: UIColor) {
setFillColor(fillColor.cgColor)
fill(rect)
}
}
Аналогично, соответствие нашему типу SVG не является очень сложным. Мы преобразуем прямоугольник в набор XML-атрибутов и конвертируем UIColor в шестнадцатеричную строку (например, белый цвет становится #ffffff):
extension SVG: DrawingContext {
mutating func addEllipse(rect: CGRect, fill: UIColor) {
var attributes: [String:String] = rect.svgEllipseAttributes
attributes["fill"] = String(hexColor: fill)
append(Node(tag: "ellipse", attributes: attributes))
}
mutating func addRectangle(rect: CGRect, fill: UIColor) {
var attributes: [String:String] = rect.svgAttributes
attributes["fill"] = String(hexColor: fill)
append(Node(tag: "rect", attributes: attributes))
}
}
(Определения SVG, CGRect.svgAttributes, CGRect.svgEllipseAttributes и String.init(hexColor:) здесь не показаны; они не важны для примера.)
Расширения Протоколов Link to heading
Одной из ключевых особенностей протоколов Swift являются расширения протоколов. Как только мы научились рисовать эллипсы, мы также можем добавить расширение для рисования кругов вокруг центральной точки. Мы делаем это как расширение DrawingContext:
extension DrawingContext {
mutating func addCircle(center: CGPoint, radius: CGFloat, fill: UIColor) {
let diameter = radius * 2
let origin = CGPoint(x: center.x - radius, y: center.y - radius)
let size = CGSize(width: diameter, height: diameter)
let rect = CGRect(origin: origin, size: size)
addEllipse(rect: rect.integral, fill: fill)
}
}
Чтобы использовать вышеуказанное, мы можем написать еще одно расширение для DrawingContext, которое рисует синий круг поверх большого желтого квадрата:
extension DrawingContext {
mutating func drawSomething() {
let rect = CGRect(x: 0, y: 0, width: 100, height: 100)
addRectangle(rect: rect, fill: .yellow)
let center = CGPoint(x: rect.midX, y: rect.midY)
addCircle(center: center, radius: 25, fill: .blue)
}
}
Записывая метод как расширение DrawingContext, мы теперь можем вызывать его как на экземплярах SVG, так и на экземплярах CGContext. Это техника, используемая на протяжении всей стандартной библиотеки Swift: хотя соответствие протоколу требует реализации нескольких методов, вы затем получаете гораздо больше функциональности «бесплатно» через расширения этого протокола.
Настройка Расширений Протоколов Link to heading
Добавление метода в качестве расширения протокола не делает его частью требований протокола. Это может привести к неожиданному поведению, поскольку только требования динамически обрабатываются. Давайте посмотрим, что мы имеем в виду. Вернемся к нашему примеру: мы хотим использовать элемент circle из SVG, т.е. круг должен отображаться как <circle>, а не как <ellipse>. Давайте добавим специализированный метод addCircle в нашу реализацию SVG:
extension SVG {
mutating func addCircle(center: CGPoint, radius: CGFloat, fill: UIColor) {
let attributes = [
"cx": "\(center.x)",
"cy": "\(center.y)",
"r": "\(radius)",
"fill": String(hexColor: fill),
]
append(Node(tag: "circle", attributes: attributes))
}
}
Когда мы создаем переменную типа SVG и вызываем addCircle на ней, будет вызван вышеуказанный метод:
var circle = SVG()
circle.addCircle(center: .zero, radius: 20, fill: .red)
circle
/*
<svg>
<circle cx="0.0" cy="0.0" fill="#ff0000" r="20.0"/>
</svg>
*/
Ничего удивительного. Но когда мы вызываем drawSomething() на Drawing (который содержит вызов addCircle), вышеуказанный метод не будет вызван. Обратите внимание, что результирующий синтаксис SVG содержит тег <ellipse> вместо тега <circle>, который мы хотели получить:
var drawing = SVG()
drawing.drawSomething()
drawing
/*
<svg>
<rect fill="#ffff00" height="100.0" width="100.0" x="0.0" y="0.0"/>
<ellipse cx="50.0" cy="50.0" fill="#0000ff" rx="25.0" ry="25.0"/>
</svg>
*/
Это поведение может быть неожиданным. Чтобы понять, что происходит, мы сначала перепишем наше расширение drawSomething как свободную функцию с явными обобщениями. Это имеет точно такие же семантики, как и расширение протокола, которое мы написали ранее:
func drawSomething<D: DrawingContext>(context: inout D) {
let rect = CGRect(x: 0, y: 0, width: 100, height: 100)
context.addRectangle(rect: rect, fill: .yellow)
let center = CGPoint(x: rect.midX, y: rect.midY)
context.addCircle(center: center, radius: 25, fill: .blue)
}
Здесь обобщенный параметр D ограничен протоколом DrawingContext. Это означает, что компилятор передаст свидетельство протокола для протокола DrawingContext, когда мы его вызовем. Свидетельство содержит только требования протокола, т.е. методы addRectangle и addEllipse. Поскольку метод addCircle определен только в расширении и не в протоколе, он не включен в свидетельство.
Суть в том, что только методы, которые являются частью свидетельства, могут динамически вызываться для специализированной конкретной реализации в соответствующем типе, поскольку свидетельство — это единственная динамическая информация, доступная во время выполнения. Вызовы к не требованиям в обобщенных контекстах всегда обрабатываются статически для реализации, определенной в расширении протокола.
В результате, когда мы вызываем addCircle из функции drawSomething, он статически обрабатывается в расширении нашего протокола. Компилятор просто не может сгенерировать код, который был бы необходим для динамической обработки вызова к нашему расширению на SVG.
Чтобы получить поведение динамической обработки, мы должны сделать addCircle требованием протокола:
protocol DrawingContext {
mutating func addEllipse(rect: CGRect, fill: UIColor)
mutating func addRectangle(rect: CGRect, fill: UIColor)
mutating func addCircle(center: CGPoint, radius: CGFloat, fill: UIColor)
}
Существующая реализация addCircle в расширении протокола теперь становится стандартной реализацией требования. Поскольку стандартная реализация существует, нам не нужно делать ничего другого, чтобы наш код скомпилировался. Теперь, когда addCircle является частью протокола, он также является частью свидетельств протокола, и когда мы вызываем drawSomething с значением SVG, будет вызвана правильная пользовательская реализация:
var drawing2 = SVG()
drawing2.drawSomething()
drawing2
/*
<svg>
<rect fill="#ffff00" height="100.0" width="100.0" x="0.0" y="0.0"/>
<circle cx="50.0" cy="50.0" fill="#0000ff" r="25.0"/>
</svg>
*/
Метод протокола с стандартной реализацией иногда называется точкой настройки в сообществе Swift. Соответствующие типы получают стандартную реализацию, но могут переопределить ее, если решат это сделать. Стандартная библиотека широко использует точки настройки для предоставления общих стандартов, которые могут быть переопределены, часто по причинам производительности. Примером является метод distance(from:to:) для вычисления расстояния между двумя индексами коллекции. Стандартная реализация имеет сложность O(n), так как она проходит по всем индексам коллекции. Поскольку distance(from:to:) является точкой настройки, типы коллекций, которые могут предоставить более эффективную реализацию, такие как Array, могут переопределить стандартную реализацию.
Композиция Протоколов Link to heading
Протоколы могут быть объединены в составной протокол. Примером из стандартной библиотеки является Codable; это типовой алиас для Encodable & Decodable:
typealias Codable = Decodable & Encodable
Это означает, что мы можем написать следующую функцию, и в её теле мы можем использовать оба протокола при работе со значением:
func useCodable<C: Codable>(value: C) {
// ...
}
В нашем примере с рисованием мы можем захотеть отрисовать некоторые атрибутированные строки (это строки, в которых поддиапазоны имеют атрибуты форматирования, такие как выделение, шрифты и цвета). Однако формат SVG не поддерживает атрибутированные строки нативно (но CoreGraphics поддерживает). Вместо того чтобы добавлять метод в наш протокол DrawingContext, мы создадим отдельный протокол:
protocol AttributedDrawingContext {
mutating func draw( _ str: AttributedString, at point: CGPoint)
}
Таким образом, мы можем сделать CGContext соответствующим новому протоколу, но нам не нужно добавлять поддержку SVG. Затем мы можем объединить два протокола — например, с расширением DrawingContext, которое ограничено типами, которые также соответствуют AttributedDrawingContext:
extension DrawingContext where Self: AttributedDrawingContext {
mutating func drawSomething2() {
let size = CGSize(width: 200, height: 100)
addRectangle(rect: .init(origin: .zero, size: size), fill: .red)
var text = AttributedString("Hello")
text.font = UIFont.systemFont(ofSize: 48)
draw(text, at: CGPoint(x: 20, y: 20))
}
}
В качестве альтернативы, мы могли бы написать функцию с явными обобщениями. Как и прежде, это семантически эквивалентно методу выше:
func drawSomething2<C: DrawingContext & AttributedDrawingContext>( _ c: inout C) {
// ...
}
Составление протоколов может быть очень мощным способом оптимизации функций, которые могут не быть реализованы каждым соответствующим типом.
Наследование Протоколов Link to heading
Вместо того чтобы составлять наш протокол при его использовании, мы также можем позволить протоколам наследовать друг от друга. Например, мы могли бы написать наше определение AttributedDrawingContext так:
protocol AttributedDrawingContext: DrawingContext {
mutating func draw( _ str: AttributedString, at point: CGPoint)
}
Это определение требует, чтобы любой тип, который соответствует AttributedDrawingContext, также соответствовал DrawingContext.
Как наследование протоколов, так и составление протоколов имеют свои случаи использования. Например, протокол Comparable наследует от Equatable. Это означает, что он может добавлять определения, такие как >= и <=, при этом требуя от соответствующих типов реализовать только <. В случае с Codable не имело бы смысла позволять Encodable наследовать от Decodable или наоборот. Однако имело бы смысл определить новый протокол с именем Codable, который наследует от обоих Encodable и Decodable. На самом деле, запись typealias Codable = Encodable & Decodable семантически эквивалентна записи protocol Codable: Encodable, Decodable {}. typealias немного легче и делает ясным, что Codable является лишь композицией двух протоколов и не добавляет никакой функциональности.
Ассоциированные типы Link to heading
Некоторые протоколы требуют больше, чем просто методы, свойства и инициализаторы: им нужны один или несколько связанных типов, чтобы быть полезными. Это можно достичь с помощью ассоциированных типов. Хотя мы не часто пишем протоколы с ассоциированными типами в нашем собственном коде, стандартная библиотека использует ассоциированные типы довольно часто. Один из самых маленьких примеров из стандартной библиотеки — это протокол IteratorProtocol. У него есть ассоциированный тип для элементов, которые перебираются, вместе с единственным методом для получения следующего элемента:
protocol IteratorProtocol {
associatedtype Element
mutating func next() -> Element?
}
Обратите внимание, что требование next использует ассоциированный тип в своем возвращаемом типе. Когда вы пишете соответствие протоколу, вы указываете конкретный тип, который будет использоваться для Element. Следующий пример определяет итератор для элементов типа Int:
struct Counter: IteratorProtocol {
typealias Element = Int
func next() -> Int? { ... }
}
typealias явно устанавливает ассоциированный тип. Мы могли бы опустить его, и компилятор выведет ассоциированный тип из возвращаемого типа метода next. Этот небольшой пример показывает, что ассоциированные типы позволяют автору протокола определить требования протокола в терминах одного или нескольких связанных типов. Конкретные типы, которые будут использоваться, не нужно знать до тех пор, пока протокол не будет реализован.
Ассоциированные типы могут иметь значения по умолчанию, записанные с помощью знака равенства после объявления ассоциированного типа. Протокол Collection имеет пять ассоциированных типов, и многие из них имеют значения по умолчанию. Например, ассоциированный тип SubSequence имеет значение по умолчанию Slice<Self>:
public protocol Collection: Sequence {
associatedtype Iterator = IndexingIterator<Self>
associatedtype SubSequence: Collection = Slice<Self>
...
}
Ассоциированный тип со значением по умолчанию становится еще одной точкой настройки, так же как и метод с реализацией по умолчанию: при соответствии пользовательского типа протоколу Collection вы можете придерживаться значения по умолчанию и сэкономить немного работы по реализации в результате. Однако некоторые коллекции переопределяют свой тип среза, часто для повышения производительности или удобства (например, String использует Substring в качестве SubSequence). Мы обсуждаем все ассоциированные типы Collection подробно в главе о протоколах Collection.
Ассоциированные типы похожи на обобщенные параметры в том, что они являются заполнителями, которые заполняются позже. Что отличает их от обобщенных типов, так это то, кто заполняет эти заполнители и где. Обобщенные параметры предоставляются пользователями типа, когда они хотят использовать этот тип в своем коде. Ассоциированные типы определяются автором типа (или кем-то, кто расширяет существующий тип), когда они реализуют протокол. Например, пользователи массива могут выбрать тип элементов массива (например, Array<Int> против Array<String>), потому что авторы стандартной библиотеки предоставили это как обобщенный параметр. Но пользователи не могут настраивать итератор или тип подпоследовательности массива; они были зафиксированы соответствием Collection в стандартной библиотеке.
Self Link to heading
Внутри определения или расширения протокола, Self относится к типу, который соответствует протоколу, т.е. к типу, реализующему протокол. Вы можете рассматривать Self как неявный ассоциированный тип, который всегда доступен. Мы ранее видели, как Equatable использует Self для определения оператора ==. Вот еще один пример из протокола BinaryInteger:
public protocol BinaryInteger: ... {
func quotientAndRemainder(dividingBy rhs: Self) -> (quotient: Self, remainder: Self)
}
Это объявление устанавливает отношение одного типа между типом, реализующим протокол, и аргументом, а также между типом и двумя возвращаемыми значениями.
Пример: Восстановление состояния Link to heading
В качестве основного примера в этом разделе мы реализуем небольшую версию механизма восстановления состояния UIKit, используя протокол с ассоциированным типом. В UIKit восстановление состояния берет иерархию контроллеров представлений и представлений и сериализует их состояние, когда приложение приостанавливается. При следующем запуске приложения UIKit пытается восстановить состояние приложения.
Вместо иерархии классов мы будем использовать протоколы для обозначения контроллеров представлений. В реальной реализации протокол ViewController имел бы много методов, но ради простоты мы сделаем его пустым протоколом:
protocol ViewController {}
Чтобы восстановить конкретный контроллер представления, нам нужно иметь возможность читать и записывать его состояние. Мы хотим, чтобы это состояние соответствовало Codable, чтобы мы могли его кодировать и декодировать. Поскольку тип состояния зависит от конкретного контроллера представления, мы моделируем его как ассоциированный тип:
protocol Restorable {
associatedtype State: Codable
var state: State { get set }
}
В качестве примера мы могли бы создать контроллер представления для отображения сообщений. Состояние контроллера представления состоит из массива сообщений и текущей позиции прокрутки. Мы моделируем состояние как вложенную структуру и делаем его соответствующим Codable:
class MessagesVC: ViewController, Restorable {
typealias State = MessagesState
struct MessagesState: Codable {
var messages: [String] = []
var scrollPosition: CGFloat = 0
}
var state: MessagesState = MessagesState()
}
Обратите внимание, что нам не нужно объявлять typealias State в соответствии с протоколом; компилятор достаточно умен, чтобы вывести его, глядя на тип свойства state. Мы также могли бы переименовать нашу MessagesState в State, и все будет продолжать работать.
Условная совместимость с ассоциированными типами Link to heading
Некоторые типы соответствуют протоколу только при выполнении определенных условий. Как мы видели в разделе о условной совместимости, Array соответствует Equatable, если и только если его элементы соответствуют Equatable. Условия могут также использовать информацию об ассоциированных типах. Например, Range имеет обобщенный параметр Bound. Range соответствует Sequence, если и только если Bound является Strideable, а Stride Bound (ассоциированный тип Strideable) соответствует SignedInteger:
extension Range: Sequence
where Bound: Strideable, Bound.Stride: SignedInteger
Обратите внимание, что ограничения такой сложности являются исключением, даже в стандартной библиотеке. В нашем гипотетическом UI-фреймворке у нас также есть контроллеры представления с разделением, которые являются обобщенными по отношению к их двум дочерним контроллерам представления:
class SplitViewController<Master: ViewController, Detail: ViewController> {
var master: Master
var detail: Detail
init(master: Master, detail: Detail) {
self.master = master
self.detail = detail
}
}
Предполагая, что контроллер представления с разделением не имеет собственного состояния, мы можем объединить состояние обоих дочерних контроллеров представления. В идеале, мы хотели бы написать var state: (Master.State, Detail.State), но, увы, кортежи не соответствуют Codable — даже условно. (На самом деле, кортежи не могут соответствовать ни одному протоколу; предложение сделать все кортежи автоматически Equatable, Hashable и Comparable было принято, но еще не реализовано.) Вместо этого нам нужно написать нашу собственную обобщенную структуру Pair. Мы можем затем условно сделать ее соответствующей Codable:
struct Pair<A, B>: Codable where A: Codable, B: Codable {
var left: A
var right: B
init(_ left: A, _ right: B) {
self.left = left
self.right = right
}
}
Наконец, чтобы сделать SplitViewController совместимым с Restorable, мы должны потребовать, чтобы и Master, и Detail также были Restorable. Вместо того чтобы хранить состояние локально в нашем SplitViewController, мы вычисляем его из двух контроллеров представления, и вместо того чтобы устанавливать локальную переменную, мы немедленно передаем изменения двум дочерним элементам:
extension SplitViewController: Restorable
where Master: Restorable, Detail: Restorable {
var state: Pair<Master.State, Detail.State> {
get {
return Pair(master.state, detail.state)
}
set {
master.state = newValue.left
detail.state = newValue.right
}
}
}
Как мы отметили в разделе о условной совместимости, любой тип может соответствовать протоколу не более одного раза. Это означает, что мы не можем иметь никаких дополнительных соответствий для случая, когда Master является Restorable, а Detail — нет, или наоборот.
Ретроактивная совместимость Link to heading
Одной из основных особенностей протоколов Swift является возможность ретроактивного соответствия типов протоколу. Например, выше мы привели CGContext в соответствие с нашим протоколом Drawable. Это позволяет программистам расширять функциональность типов, которые определены в других модулях, и делать эти типы доступными для алгоритмов, использующих протокол в качестве ограничения.
Однако эту свободу можно использовать слишком далеко. При приведении типа в соответствие с протоколом мы всегда должны убедиться, что мы являемся либо владельцем типа, либо владельцем протокола (или тем и другим). Приведение типа, который вы не владеете, к протоколу, которому вы не владеете, не рекомендуется.
Например, на момент написания CLLocationCoordinate2D из фреймворка CoreLocation не соответствует протоколу Codable. Хотя легко добавить соответствие самостоятельно, наша реализация может сломаться, если и когда Apple решит привести CLLocationCoordinate2D в соответствие с Codable. В таком случае Apple может выбрать другую реализацию, и, как следствие, мы больше не сможем десериализовать существующие форматы файлов.
Конфликты соответствия также могут возникать, когда два отдельных пакета приводят тип к одному и тому же протоколу. Эта проблема возникла, когда как SourceKit-LSP, так и SwiftPM привели Range к Codable, оба с разными ограничениями. (В Swift 5 стандартная библиотека добавила соответствие Codable для Range.)
В качестве решения этих потенциальных проблем мы часто можем использовать обертки и добавлять условное соответствие там. Например, мы могли бы создать обертку-структуру вокруг CLLocationCoordinate2D и сделать так, чтобы обертка соответствовала Codable. Мы увидим пример этого в главе о кодировании и декодировании.
Экзистенциалы Link to heading
Строго говоря, протоколы не могут использоваться как конкретные типы в Swift; они могут использоваться только для ограничения обобщенных параметров. Тем не менее, следующий код компилируется без проблем (мы используем протокол DrawingContext из вышеуказанного примера):
let context: DrawingContext = SVG()
Когда мы используем протокол как конкретный тип, компилятор создает обертку для протокола, называемую экзистенциалом, за кулисами. Как свидетель и большая часть теории типов в целом, этот термин происходит из логики: DrawingContext утверждает, что существует некоторый тип, который удовлетворяет его требованиям. (В отличие от этого, обобщения создают отношения “для всех”: обобщенный тип Array утверждает, что для всех типов Element существует соответствующий тип, Array.)
Тот факт, что Swift использует одинаковый синтаксис для протокола (ограничения) и экзистенциального типа, способствовал общей путанице среди программистов Swift относительно различия между ними, особенно потому, что экзистенциалы имеют некоторые неинтуитивные ограничения, как мы увидим в следующих разделах. Это нарушает основное правило хорошего проектирования API и языка — а именно, что одни и те же или похожие концепции должны быть написаны одинаково, а разные вещи должны быть написаны по-разному. Также неудачно, что легковесный синтаксис может неправильно привлечь разработчиков к функции (экзистенциалы), использование которой обычно не рекомендуется в пользу более тяжелых по синтаксису альтернатив (обобщений и непрозрачных типов), если вам не нужна конкретная гибкость.
Соответственно, Swift 5.6 ввел новый синтаксис anyP для экзистенциалов. Старое написание останется действительным на данный момент, но оно запланировано для устаревания или удаления в будущих версиях языка. Мы будем использовать синтаксис anyP в оставшейся части этой главы:
let context: any DrawingContext = SVG()
Мы можем рассматривать anyDrawingContext как альтернативное написание для чего-то вроде Any (если бы Any был обобщенным типом), т.е. как значение Any с дополнительным ограничением. Когда компилятор видит anyDrawingContext, он создает контейнер Any (который занимает четыре слова, или 32 байта на 64-битных платформах), и добавляет однословный протокольный свидетель для каждого протокола. Мы можем проверить это для приведенного выше примера следующим образом:
MemoryLayout< Any >.size // 32
MemoryLayout<any DrawingContext>.size // 40
Этот контейнер для значений протокольного типа также называется экзистенциальным контейнером. Компилятору необходимо создавать эти контейнеры, потому что ему нужно знать размер типа во время компиляции. Поскольку разные типы с разными размерами могут соответствовать протоколу, упаковка протокола(ов) в экзистенциальный контейнер создает тип с постоянным размером, чтобы компилятор мог размещать значения в памяти. Три из четырех слов, используемых контейнером Any, используются для хранения небольших значений непосредственно в строке. Если упакованное значение больше трех слов, компилятор будет хранить его в куче и поместит указатель в контейнер. Четвертое слово хранит указатель на метаданные типа упакованного типа. Метаданные типа содержат таблицу свидетелей значений, которая предоставляет функции для базовых операций, таких как создание, уничтожение или копирование значения.
Мы можем видеть, что размер экзистенциального контейнера увеличивается с количеством протоколов, которые мы используем. Например, Codable является сокращением для Encodable и Decodable, поэтому мы ожидаем, что размер экзистенциального anyCodable будет 32 байта для контейнера Any, плюс дважды по 8 байт для протокольных свидетелей:
MemoryLayout<any Codable>.size // 48
Когда мы создаем массив anyCodable, компилятор знает, что каждый элемент занимает 48 байт, независимо от того, какой конкретный тип мы используем. Например, для массива с тремя элементами необходимо выделить 144 байта:
let codables: [any Codable] = [Int(42), Double(42), "сорок два"]
Единственное, что мы можем делать с элементами массива codables (кроме выполнения приведения типов во время выполнения с использованием as, as? или is), — это использовать API Encodable и Decodable, поскольку конкретные типы элементов скрыты экзистенциальным контейнером. Контейнер в основном невидим для программиста. Например, вызов type(of:) вернет тип упакованного значения, а не тип самого контейнера:
type(of: codables[0]) // Int
Экзистенциальные типы против обобщений Link to heading
Иногда экзистенциальные типы могут использоваться взаимозаменяемо с ограниченными обобщенными параметрами. Рассмотрим следующие две функции:
func encode1(x: any Encodable) { /* ... */ }
func encode2<E: Encodable>(x: E) { /* ... */ }
Хотя мы можем вызывать обе функции с любым типом, который соответствует протоколу Encodable, они не эквивалентны. В случае encode1 компилятор обернет параметр в контейнер экзистенциального типа any Encodable. Эта обертка требует некоторых затрат на производительность и, возможно, требует дополнительного вызова выделения памяти, если обернутое значение слишком велико, чтобы поместиться в экзистенциальный тип напрямую. Возможно, самое важное, что экзистенциальный тип предотвращает дополнительные оптимизации, поскольку все вызовы методов на обернутом значении должны проходить через таблицу свидетелей экзистенциального типа.
С другой стороны, для обобщенной функции компилятор может генерировать специализированные версии для некоторых или всех типов, с которыми вызывается encode2. Эти специализации достигают такой же производительности, как если бы мы вручную написали конкретную функцию для каждого конкретного типа. Недостатками по сравнению с версией, принимающей экзистенциальный тип, являются более длительное время компиляции и больший размер бинарного файла. Обратитесь к главе об обобщениях для получения дополнительной информации о специализированных обобщениях.
В большинстве случаев накладные расходы экзистенциальных контейнеров не являются проблемой, но это может быть полезно учитывать при оптимизации кода, критичного к производительности. Если вы вызовете одну из функций encode в цикле тысячи раз, вы, вероятно, заметите, что encode2 будет значительно быстрее. (В простых случаях оптимизатор может фактически переписать функцию, принимающую экзистенциальный тип, в эквивалентную обобщенную функцию, но общий вывод остается верным.)
Экзистенциальные типы и связанные типы Link to heading
В Swift 5.6 экзистенциальные типы ограничены протоколами, которые не имеют связанных типов и требований, ссылающихся на Self (за исключением случаев, когда Self используется как возвращаемый тип). Это давнее ограничение, которое многие разработчики Swift узнают по диагностике компилятора: «Протокол ‘P’ может использоваться только как обобщенное ограничение, потому что он имеет требования Self или связанные типы». Первоначальной причиной этой ошибки была отсутствующая функция в реализации компилятора, которая с тех пор была исправлена. Следовательно, Swift 5.7 снимет это ограничение и позволит использовать любой протокол в качестве экзистенциального типа.
Но есть более фундаментальное ограничение, которое лучший компилятор не может устранить: определенные требования протокола по своей сути несовместимы с экзистенциальными типами. Рассмотрим этот пример:
let a: any Equatable = "Alice" // Ошибка в Swift 5.5, допустимо в Swift 5.7
let b: any Equatable = "Bob"
a == b
// Ошибка Swift 5.7: бинарный оператор '==' не может быть применен
// к двум операндам 'Equatable'.
Код выше все равно не скомпилируется в Swift 5.7, потому что оператор == ссылается на Self в своих параметрах:
public protocol Equatable {
static func == (lhs: Self, rhs: Self) -> Bool
}
Функция == ожидает два аргумента, которые должны быть точно одного типа. Экзистенциальные типы не могут удовлетворить этому требованию, потому что они теряют информацию о типе, и компилятор больше не знает, что a и b на самом деле одного типа. Этот пример иллюстрирует, что ограничение «Self или связанные типы» не исчезает полностью (и не может исчезнуть) — скорее, оно смещается с уровня протокола на уровень отдельных требований протокола. Поэтому определенные требования не будут доступны для значений экзистенциального типа.
Интересно, что это не относится к требованиям, которые используют Self или связанный тип в ковариантной позиции (например, в возвращаемом типе). Этот код, использующий экзистенциальный тип FloatingPoint, будет допустим в Swift 5.7:
let number: any FloatingPoint = 1.0
print(number.nextUp) // 1.0000000000000002
Несмотря на то что FloatingPoint.nextUp возвращает Self, его можно вызывать на экзистенциальном типе. Это связано с тем, что когда Self используется как возвращаемый тип, компилятор знает, что он может снова упаковать результат в другой экзистенциальный контейнер. Статический тип выражения number.nextUp — это any FloatingPoint.
Те же правила, которые мы видели для Self, применяются к требованиям, которые ссылаются на связанные типы. Функции, которые используют связанные типы в параметрах, по-прежнему будут недоступны для экзистенциальных типов:
let anyCollection: any Collection = [1, 2, 3] // Допустимо в Swift 5.7
anyCollection.index(after: 0)
// Ошибка Swift 5.7: член 'index' не может быть использован для значения протокольного
// типа 'Collection'; используйте обобщенное ограничение вместо этого.
Тем временем требования, которые возвращают связанный тип, станут доступными:
anyCollection.first // 1 (тип - Optional<Any>)
Возвращаемый тип Collection.first — это Optional<Element>. Поскольку экзистенциальный тип устраняет конкретный тип элемента, компилятор заменяет его на Any. Статический тип anyCollection.first становится Optional<Any>.
Это работает, потому что компилятор считает Optional<T> подтипом Optional<Any>. Компилятор имеет специальное знание о том, как развернуть Optional<T> и снова упаковать обернутое значение в Optional<Any>. Другими словами, Optional является ковариантным в своем обобщенном типе. Ковариантность жестко закодирована в компиляторе, и Optional является одним из немногих типов, которые ее поддерживают. Другие — это Array и Dictionary, последний из которых для своего типа Value. Кортежи также считаются ковариантными в любом из своих элементных типов. Swift не поддерживает общие ковариантные отношения для произвольных обобщенных типов, т.е. SomeStruct<T> не является подтипом SomeStruct<Any>. Разрешение этого в общем случае сделало бы типовую систему несостоятельной.
Что если мы захотим сохранить конкретный тип элемента экзистенциальной коллекции? Было бы удобно, если бы Swift поддерживал ограничения where для экзистенциальных типов, как так:
let anyIntCollection: any Collection where Element == Int // Неправильный синтаксис
Это еще не поддерживается, но есть хорошие шансы, что что-то подобное будет в будущем. В Swift 5.7 мы сможем достичь аналогичного эффекта с помощью обходного пути. Мы можем ввести новый протокол, который наследует от базового протокола и добавляет ограничение для связанного типа, который мы хотим зафиксировать:
protocol IntCollection: Collection where Element == Int {}
extension Array: IntCollection where Element == Int {}
let anyIntCollection: any IntCollection = [1, 2, 3]
anyIntCollection.first // 1 (тип - Optional<Int>)
Мы фактически переместили ограничение where на экзистенциальный тип в новый протокол, который кодирует то же ограничение. Это дает компилятору достаточно информации, чтобы знать, что статический тип любого API протокола, который использует Element, должен быть Int.
Экзистенциалы не соответствуют протоколам Link to heading
«Протоколы не соответствуют сами себе» — это известное изречение в сообществе Swift. Теперь, когда мы увидели, что протоколы и неявные сгенерированные компилятором типы протоколов (экзистенциалы) — это разные вещи, мы можем сформулировать это более точно: экзистенциальный тип не соответствует «своему» протоколу с тем же именем. Другими словами, вы не можете передать экзистенциал типа any P в обобщенную функцию func f<T: P>(_ x: P).
Основная причина этого ограничения такая же, как и в предыдущем разделе: не все требования доступны для экзистенциалов. В дополнение к требованиям, которые ссылаются на Self или ассоциированные типы, это также относится к инициализаторам и статическим методам, которые можно рассматривать как особые случаи функций, принимающих Self в качестве первого аргумента.
Возьмем протокол Decodable в качестве примера. Тип JSONDecoder предоставляет этот метод для декодирования значения JSON:
extension JSONDecoder {
func decode<T: Decodable>(_: T.Type, from data: Data) throws -> T
}
Метод decode в конечном итоге вызовет T.init(from:), который является требованием инициализатора, входящим в протокол Decodable. Если бы было разрешено передавать экзистенциальный тип (any Decodable).self в decode, компилятор не знал бы, какой тип инстанцировать. Логично, что это незаконно:
let json = #"{ "email": "alice@example.com" }"#
let jsonData = Data(json.utf8) // 32 байта
let decoded = try JSONDecoder().decode((any Decodable).self, from: jsonData)
// Ошибка: протокол 'Decodable' как тип не может соответствовать самому протоколу.
В отличие от ограничения на переменные типов протоколов, которое мы видели, перемещенное от всего протокола к отдельным требованиям, ограничение на «самосоответствие» экзистенциалов по-прежнему применяется универсально ко всем протоколам — за исключением протокола Error.
Экзистенциальный тип Error действительно соответствует протоколу Error. Этот специальный случай закодирован в компиляторе, чтобы позволить any Error (экзистенциал) использоваться для обобщенных параметров, которые ограничены протоколом Error. Без этого очень распространенные типы, такие как Result<Int, any Error> (записываемый как Result<Int, Error> до Swift 5.6), были бы незаконными — вам пришлось бы указать конкретный тип ошибки, такой как Result<Int, URLError>. Эта магия компилятора возможна, потому что протокол Error является чисто маркерным протоколом без каких-либо требований, поэтому нет проблем с отсутствием требований.
Не Используйте Экзистенциальные Типы Преждевременно Link to heading
Как правило, вы должны предпочитать обобщения (generics) работе с протоколами, если вам не нужна дополнительная гибкость, которую предоставляет упаковка (boxing), например, для хранения гетерогенных значений в коллекции. Экзистенциальные типы теряют информацию о типе, в то время как обобщения сохраняют её. Постоянная упаковка и распаковка из экзистенциальных контейнеров негативно сказывается на производительности и сама по себе; кроме того, преждевременное стирание типов неизбежно блокирует некоторые оптимизации компилятора.
Более того, мы видели, что даже в будущих релизах Swift будут сняты некоторые ограничения, которые в настоящее время все еще существуют для протокольных типов, однако существуют фундаментальные ограничения, которые делают некоторые API постоянно недоступными для значений экзистенциального типа. Разблокировка экзистенциальных типов для всех протоколов устранит источник путаницы, с которым сталкиваются почти все разработчики Swift. Мы считаем, что это хорошо, хотя это неизбежно вводит новые источники запутанных диагностик (например, почему я могу вызвать этот метод протокола, но не могу вызвать тот?).
Существует реальный риск, что неопытные разработчики начнут использовать экзистенциальные типы повсюду — не в последнюю очередь из-за удобного синтаксиса — и затем узнают через часы или дни, что им следовало использовать обобщения с ограничениями протоколов. Страх пригласить такие “подводные камни” был важным фактором в колебаниях команды Swift по поводу снятия ограничений на экзистенциальные типы.
Одна из основных причин использования статически типизированного языка с такой же сильной системой типов, как у Swift, заключается в том, чтобы предоставить компилятору как можно больше информации для выполнения его задач. Ненужное стирание типов работает против этого.
Непрозрачные типы Link to heading
Непрозрачные типы — это способ для автора API скрыть конкретный возвращаемый тип этого API, не прибегая к экзистенциальным типам (которые бы стерли информацию о типе). Это достигается с помощью синтаксиса some MyProtocol, который обозначает «некоторый конкретный тип, удовлетворяющий указанным ограничениям». Основной конкретный тип скрыт от клиентов; они могут манипулировать им только через объявленные возможности (в данном случае, MyProtocol).
На первый взгляд, это похоже на экзистенциальный тип, но, в отличие от экзистенциальных типов, система типов сохраняет (скрытую) идентичность типа непрозрачного типа. Это позволяет клиентам выполнять некоторые действия с непрозрачными типами, которые были бы недоступны для экзистенциальных типов, такие как вызов API с использованием Self или ограничений на ассоциированные типы. Более того, непрозрачный тип some MyProtocol действительно соответствует протоколу, в то время как мы видели, что экзистенциальный тип — нет. Непрозрачные типы также, как правило, более эффективны и легче для оптимизации компилятором, чем экзистенциальные типы.
Скрытие информации Link to heading
Apple широко использует непрозрачные типы в SwiftUI. Без них написание и чтение кода SwiftUI было бы настолько неудобным, что дизайн API SwiftUI, вероятно, выглядел бы совершенно иначе. Аспект скрытия информации непрозрачных типов имеет две стороны: (a) упрощение использования глубоко вложенных обобщенных типов и (b) скрытие деталей реализации. Мы более подробно рассмотрим обе стороны на большем примере.
Давайте создадим небольшой синтаксис разметки, вдохновленный Markdown, для форматирования текста. Мы хотим определить API для применения атрибутов форматирования к тексту и генерации результирующей разметки. Начнем с определения протокола для представления богатого текста, который может быть отрисован в виде разметки:
protocol RichText {
func render() -> String
}
Мы добавим поддержку только жирного и курсивного текста, но начиная с протокола, мы позволим пользователям расширять систему своим собственным синтаксисом разметки.
Для простого, неформатированного текста мы можем сделать String соответствующим RichText:
extension String: RichText {
func render() -> String { self }
}
Далее, давайте создадим тип для представления жирного текста. Эта структура хранит значение RichText и применяет правильную разметку для отрисовки:
struct Bold<Text: RichText>: RichText {
var text: Text
func render() -> String {
"**\(text.render())**"
}
}
Обратите внимание, что мы решили сделать структуру обобщенной по отношению к входному тексту. Это не единственный вариант (мы могли бы использовать экзистенциальный тип вместо этого), но это кажется уместным, учитывая общее правило избегать преждевременного стирания типов.
Тип для представления курсивного текста выглядит почти идентично:
struct Italic<Text: RichText>: RichText {
var text: Text
func render() -> String {
"_\(text.render())_"
}
}
А вот пример использования того, что у нас есть на данный момент:
Bold(text: "жирный").render() // **жирный**
Bold(text: Italic(text: "жирный и курсивный")).render() // **_жирный и курсивный_**
Чтобы моделировать текст с различным форматированием, нам понадобится способ комбинирования нескольких фрагментов текста. Мы можем определить структуру Concat, которая объединяет два значения RichText:
struct Concat<Text1: RichText, Text2: RichText>: RichText {
var a: Text1
var b: Text2
func render() -> String {
"\(a.render())\(b.render())"
}
}
Это работает, но синтаксис неудобен: объединение более чем двух фрагментов требует вложенности. Добавление некоторых “модификаторов” в стиле SwiftUI к протоколу дает нам более плавный API:
extension RichText {
func bold() -> Bold<Self> {
Bold(text: self)
}
func italic() -> Italic<Self> {
Italic(text: self)
}
func appending<Other: RichText>( _ other: Other) -> Concat<Self, Other> {
Concat(a: self, b: other)
}
}
Вот новый API в действии:
let text = "Привет,"
.bold()
.appending(" ")
.appending(
"мир!".italic()
)
text.render() // **Привет,** _мир!_
Отлично! Если вы видели код SwiftUI, это должно выглядеть очень знакомо. Однако не так приятно, что тип значения text, Concat<Concat<Bold, String>, Italic>. Это иллюстрирует две распространенные проблемы с глубоко вложенными обобщенными типами:
Вложенные обобщенные типы быстро становятся трудными для поддержки. Вывод типов может помочь с этим в некоторой степени, но объявления функций требуют явных типов возвращаемых значений. Представьте себе функцию, которая создает некоторый отформатированный текст и возвращает его. Вам не только придется записывать полный тип возвращаемого значения в сигнатуре функции, что затрудняет его восприятие для пользователей, но вам также придется обновлять этот тип каждый раз, когда вы изменяете структуру возвращаемого значения (например, добавляя еще один фрагмент текста).
Тип раскрывает детали реализации. Как только вы публикуете сигнатуру типа как публичный API, может быть невозможно изменить ее без нарушения работы клиентов. Библиотека, которая заставляет своих пользователей обновлять свой код каждый раз, когда она обновляет какое-то форматирование текста, не будет очень удобной для пользователей. Более того, библиотека разметки текста может не хотеть раскрывать такие типы, как Bold или Concat, если вызывающие функции должны взаимодействовать только с интерфейсом RichText.
Непрозрачные типы решают эти проблемы, позволяя нам заменить конкретный тип возвращаемого значения, такой как Concat<Self, Other>, на некоторый RichText. Давайте сделаем это для наших модификаторов текста (реализации остаются неизменными):
extension RichText {
func bold() -> some RichText {
Bold(text: self)
}
func italic() -> some RichText {
Italic(text: self)
}
func appending<Other: RichText>( _ other: Other) -> some RichText {
Concat(a: self, b: other)
}
}
Сигнатуры вызовов остаются неизменными:
let text = "Привет,"
.bold()
.appending(" ")
.appending("мир!".italic())
text.render() // **Привет,** _мир!_
Разница в том, что конкретный тип text теперь скрыт, как если бы API вернул экзистенциальный тип. Новые сигнатуры функций лучше отражают контракт, который мы, авторы библиотеки RichText, хотим предоставить клиентам. Поскольку вызывающие функции могут получить доступ только к заявленным возможностям непрозрачного типа (здесь RichText), клиентам не нужно знать о внутренних типах, и никому не нужно обновлять свой код, если основной конкретный тип когда-либо изменится.
Правила для непрозрачных типов Link to heading
Формальные правила для непрозрачных типов изложены ниже.
Непрозрачные типы могут появляться в качестве возвращаемого типа функций, свойств/переменных или индексов. Их также называют непрозрачными типами результата, потому что они касаются исключительно типов выходных данных. Swift 5.7 расширит синтаксис
some Pдля параметров функций как легковесный альтернативный синтаксис для обобщенных параметров. Однако обратите внимание, что, несмотря на идентичное написание, существует важное семантическое различие между непрозрачными параметрами функций и непрозрачными типами результата. Когдаsome Pпоявляется в параметре функции, вызывающий определяет тип, который передается в функцию, в то время как конкретный тип, скрытый за непрозрачным типом результата, выбирается автором функции.Ограничение обычно является протоколом, но также может быть ограничением класса (например,
some UIViewозначает любой подкласс UIView) или композицией нескольких ограничений (например,some AnyObject & Encodable).Функция с непрозрачным типом должна возвращать один и тот же тип во всех кодовых путях. Например, эта альтернативная реализация добавления, которая просто возвращает
self, когда добавляемый текст пуст, является недопустимой:
extension RichText {
// Ошибка: функция объявляет непрозрачный возвращаемый тип, но операторы return в ее теле не имеют совпадающих базовых типов.
func appending2<Other: RichText>( _ other: Other) -> some RichText {
if other.render().isEmpty {
return self
} else {
return Concat(a: self, b: other)
}
}
}
Компилятор гарантирует, что оба кодовых пути возвращают один и тот же тип. Хотя конкретный тип не известен за пределами тела функции, система типов рассматривает тип “возвращаемый тип appending2” как единую сущность, которая никогда не меняется. Это важное отличие по сравнению с тем, как обрабатываются экзистенциальные типы.
Вы можете задаться вопросом, как SwiftUI позволяет использовать условные конструкции с разными типами представлений в каждой ветке. Например, это совершенно законно:
struct ContentView: View {
var rounded: Bool
var body: some View {
if rounded {
Circle()
} else {
Rectangle()
}
}
}
Это работает, потому что геттер для свойства body может неявно стать функцией-строителем результата. Обратите внимание, что мы не написали (и не могли бы написать) операторы return в ветвях if / else. Конкретный тип непрозрачного some View здесь не является ни Circle, ни Rectangle, а представляет собой _ConditionalContent<Circle, Rectangle>. См. главу о функциях для получения дополнительной информации о строителях результата.
- Функция с непрозрачным типом должна возвращать один и тот же конкретный тип при каждом вызове. Компилятор “знает” это и может использовать эту информацию. Например, мы можем вызывать функцию, которая возвращает
some BinaryInteger, несколько раз и складывать возвращаемые значения:
func randomNumber() -> some BinaryInteger {
Int16.random(in: 1...20)
}
let a = randomNumber() // 20
let b = randomNumber() // 3
a + b // 23
Если бы randomNumber возвращал экзистенциальный тип any BinaryInteger, это вызвало бы ошибку, потому что оператор + определен только для операндов одного типа, а экзистенциальные типы могут упаковывать любой соответствующий тип.
- Вы можете восстановить конкретный тип через динамическое приведение типов. Непрозрачные типы известны только статической системе типов; у них нет представления во время выполнения. Вызов
type(of:)для непрозрачного значения возвращает фактический конкретный тип. Чтобы проверить, имеет ли непрозрачное значение определенный конкретный тип, вы можете использовать операторыisилиas?:
if let d = randomNumber() as ? Int16 {
print("\(d) is an Int16")
} else {
print("Это какой-то другой тип")
} /*end*/
// 4 is an Int16
Ограничения непрозрачных типов Link to heading
SwiftUI продемонстрировала полезность непрозрачных типов для скрытия глубоко вложенных сигнатур обобщенных типов. Наш собственный пример RichText по своей сути схож (на гораздо меньшем масштабе). Непрозрачные типы в Swift 5.6 имеют некоторые значительные ограничения, которые ограничивают их полезность для многих других случаев использования. Мы ожидаем, что эти ограничения будут сняты в будущих релизах, что сделает непрозрачные типы более широко применимыми.
Оборачивание непрозрачных типов в другие типы не поддерживается. Например, функция в настоящее время не может возвращать необязательный непрозрачный тип,
(some P)?, или кортеж непрозрачных типов,(some P, some Q). Это ограничение будет снято в Swift 5.7.Идентичность непрозрачного типа связана с местом его объявления. То есть невозможно выразить, что две функции возвращают один и тот же непрозрачный тип, что делает следующий код недопустимым:
func positiveNumber() -> some BinaryInteger {
Int16.random(in: 1...20)
}
func negativeNumber() -> some BinaryInteger {
-Int16.random(in: 1...20)
}
let c = positiveNumber() // 6
let d = negativeNumber() // -2
c + d
// Ошибка: оператор '+' не может быть применен к операндам типа
// 'some BinaryInteger' (результат 'positiveNumber()') и
// 'some BinaryInteger' (результат 'negativeNumber()').
То, как компилятор иногда ссылается на непрозрачные типы в диагностике (“‘some BinaryInteger’ (результат ‘positiveNumber()’)”), требует привыкания, но это иллюстрирует, что уникальная идентичность типа привязана к функции positiveNumber, а не к ограничению some BinaryInteger. Это ограничение может быть снято путем введения синтаксиса для “псевдонимов непрозрачных типов”, который позволит программистам определить псевдоним типа для ограничения непрозрачного типа, который они затем смогут использовать в нескольких объявлениях.
- Непрозрачные типы не могут иметь ограничения where. Это большая недостающая функциональность, которая делает непрозрачные типы гораздо менее полезными, чем они могли бы быть. В то же время, когда Apple представила SwiftUI, она также выпустила реактивный фреймворк Combine. Он использует глубоко вложенные обобщенные типы с громоздкими сигнатурами типов. Например, вот типичный издатель (название Combine для потока событий) с несколькими примененными преобразованиями:
let url: URL = ...
let downloadedUser = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
Тип этой переменной:
Publishers.Decode<
Publishers.MapKeyPath<URLSession.DataTaskPublisher, Data>,
User,
JSONDecoder
>
Его трудно читать и он не дает читателю необходимой информации: каковы типы вывода и ошибки этого издателя? Многие пользователи Combine прибегают к добавлению вызова .eraseToAnyPublisher() к своим цепочкам издателей. Это выполняет ручное стирание типа, о котором мы подробно поговорим позже в главе. Это решает непосредственную проблему — тип становится AnyPublisher<User, Error> — но это связано с обычными недостатками преждевременного стирания типа.
На первый взгляд, непрозрачные типы должны быть идеальным решением для этого: мы хотим скрыть несущественные конкретные типы от пользователей, открывая только соответствующий протокол. Действительно, мы можем написать let publisher: some Publisher, но, к сожалению, это открывает слишком мало информации — вы не можете действительно сделать что-то полезное с издателем, если не знаете его связанные типы Output и Failure. Что нам нужно, так это ограничения where для непрозрачных типов, чтобы мы могли написать что-то вроде этого:
let downloadedUser: some Publisher where .Output == User, .Failure == Error = ...
Этот синтаксис в настоящее время не поддерживается, но мы надеемся, что мы увидим его в будущей версии Swift. Это сделает непрозрачные типы гораздо более способными и универсально применимыми.
Представление стандартной библиотеки с непрозрачными типами Link to heading
Если бы Swift изначально имел непрозрачные типы результатов (с ограничениями where), стандартная библиотека, вероятно, выглядела бы иначе. Многие API коллекций, которые в настоящее время возвращают конкретный тип, были бы более понятными при использовании непрозрачных типов. Вот несколько вымышленных примеров:
extension Sequence {
func enumerated() -> some Sequence
where .Element == (offset: Int, element: self .Element)
}
extension BidirectionalCollection {
func reversed() -> some BidirectionalCollection
where .Element == self .Element
}
func zip<S1: Sequence, S2: Sequence>( _ seq1: S1, _ seq2: S2) -> some Sequence where .Element == (S1.Element, S2.Element)
Текущие конкретные типы, которые представляют эти последовательности — EnumeratedSequence, ReversedSequence, Zip2Sequence и многие другие — все еще существовали бы, но им не обязательно было бы быть публичными. Ограничивая доступ только к важным частям, API лучше описывали бы контракт между вызывающим и вызываемым.
Эволюция библиотеки поддержки непрозрачных типов Link to heading
Непрозрачные типы предоставляют стабильный бинарный интерфейс между модулями. Библиотека может свободно изменять конкретный тип, скрытый за непрозрачным типом результата, не нарушая работу своих клиентов, даже если клиенты динамически связывают библиотеку. Например, если Apple обновит реализацию некоторого API SwiftUI, чтобы возвращать другой конкретный тип представления, существующие приложения, которые вызывают этот API, не сломаются.
Это работает, потому что код, который получает доступ к экземпляру непрозрачного типа, делает это косвенно через таблицу свидетельств значений типа. Поскольку макет памяти значения неизвестен на этапе компиляции, компилятор вставляет код, который проходит через таблицу свидетельств значений каждый раз, когда доступается непрозрачное значение. Это тот же механизм, который мы обсуждали в разделе “Как работают обобщения” в предыдущей главе.
Как и обобщенные значения (в отличие от экзистенциалов), само непрозрачное значение не упаковано — Int, скрытый за некоторым BinaryInteger, имеет точно такой же макет памяти, как обычный Int. Это хорошо для производительности, потому что значения могут быть плотно упакованы в памяти и полностью использовать кэши ЦП.
Естественно, косвенный доступ к значениям не дается даром. Эта стоимость неизбежна, когда поддержание бинарной совместимости является важным (как мы видели с SwiftUI), но это не всегда так. Когда тело функции с непрозрачным типом возвращаемого значения видно вызывающему (из-за инлайнинга или потому, что вызывающий и вызываемый находятся в одном модуле), оптимизатор может фактически увидеть конкретный тип, который функция возвращает, и устранить любую косвенность. Это не меняет семантику непрозрачных типов с точки зрения программиста, но делает много кода, использующего непрозрачные типы, таким же быстрым, как если бы использовались конкретные типы.
Непрозрачные Типы и Экзистенциалы Link to heading
Как непроницаемые типы, так и экзистенциалы скрывают конкретный тип от статической системы типов и позволяют доступ только через указанные ограничения протоколов. Как мы уже видели, основное различие заключается в том, что непроницаемые типы сохраняют идентичность типа, а экзистенциалы ее стирают. Если вам не нужна гибкость, которую предоставляют экзистенциалы, обычно рекомендуется придерживаться непроницаемых типов, так как они лучше выражают большинство контрактов API (“эта функция всегда возвращает один и тот же тип, просто конкретный тип не важен”). Это также дает выигрыш в производительности, так как компилятор обычно может лучше оптимизировать непроницаемые типы.
Type Erasers Link to heading
Мы видели, что экзистенциалы являются формой стирания типов. Из-за текущих ограничений, которые ограничивают экзистенциалы протоколами без каких-либо требований к Self или связанным типам, иногда бывает желательно писать типы, которые выполняют стирание типов, но не имеют тех же ограничений на использование, что и экзистенциалы. Мы называем эти типы (ручные) стиратели типов. Даже более гибкие экзистенциалы в Swift 5.7 не покроют все случаи использования (в частности, стиратели типов, которые сохраняют один или несколько связанных типов), поэтому написание ручных стирателей типов остается актуальным, по крайней мере, на некоторое время.
Например, рассмотрим следующее выражение:
let seq = [1, 2, 3].lazy.filter { $0 > 1 }.map { $0 * 2 }
Его тип — LazyMapSequence<LazyFilterSequence<[Int]>, Int>. По мере добавления большего количества операций тип становится еще более сложным. Иногда мы можем захотеть стереть этот тип и «просто» вернуть Sequence с элементами типа Int. Использование непрозрачного типа (some Sequence) или экзистенциала (с Swift 5.7) может выразить первое ограничение (Sequence), но не второе (элементы Int). Удобно, что структура AnySequence позволяет скрыть основной тип:
let anySeq = AnySequence(seq)
Тип anySeq — AnySequence<Int>. Хотя его гораздо проще читать и он имеет тот же интерфейс, за это приходится платить: использование AnySequence добавляет еще один уровень косвенности и значительно медленнее, чем использование основного последовательности напрямую.
Стандартная библиотека предоставляет стиратели типов для ряда своих протоколов: например, есть также AnyCollection и AnyHashable. В оставшейся части этого раздела мы рассмотрим простую реализацию стирателя типов для нашего протокола Restorable, упомянутого ранее в этой главе. Вот он снова:
protocol Restorable {
associatedtype State: Codable
var state: State { get set }
}
В качестве первой попытки мы можем написать наш AnyRestorable, как показано ниже. Однако это не сработает, потому что основной R все еще будет виден в обобщенном параметре — мы ничего не выиграли:
struct AnyRestorable<R: Restorable> {
var restorable: R
}
Вместо этого мы хотим, чтобы AnyRestorable был обобщенным только по State. Это распространенный шаблон для стирателей типов: они стирают конкретный соответствующий тип, сохраняя один связанный тип, который важен для пользователей стирателя типов. Например, AnyCollection<Element> сохраняет тип элементов стираемой коллекции, потому что большинству клиентов нужен тип элемента для работы с коллекцией. Все остальные специфические для соответствия Collection, включая другие связанные типы, стираются.
Чтобы сделать AnyRestorable соответствующим Restorable, нам также нужно предоставить свойство state. Мы будем использовать ту же технику реализации, что и стандартная библиотека: она использует три класса для реализации одного стирателя типов. Прежде всего, мы создадим абстрактный класс AnyRestorableBoxBase, который является обобщенным только по State, а не по Restorable. Мы будем соответствовать Restorable, реализуя state с помощью fatalError. Этот класс является приватным для реализации и никогда не будет инстанцирован:
class AnyRestorableBoxBase<State: Codable>: Restorable {
internal init () { }
public var state: State {
get { fatalError() }
set { fatalError() }
}
}
Далее мы создаем подкласс AnyRestorableBoxBase, который является обобщенным по R. Специальный трюк, который делает стирание типов работающим, заключается в том, чтобы ограничить обобщенный параметр AnyRestorableBoxBase быть тем же, что и R.State базового класса:
class AnyRestorableBox<R: Restorable>: AnyRestorableBoxBase<R.State> {
var r: R
init ( _ r: R) {
self.r = r
}
override var state: R.State {
get { r.state }
set { r.state = newValue }
}
}
Отношение подкласса означает, что мы можем создать экземпляр AnyRestorableBox, но используем его так, как будто это AnyRestorableBoxBase, который удобно только выставляет обобщенный параметр State. Поскольку последний класс соответствует Restorable, мы можем использовать его как Restorable сразу. В качестве последнего шага мы создаем обертку-структуру AnyRestorable, которая скрывает AnyRestorableBox:
struct AnyRestorable<State: Codable>: Restorable {
private let box: AnyRestorableBoxBase<State>
init <R>( _ r: R) where R: Restorable, R.State == State {
self.box = AnyRestorableBox(r)
}
var state: State {
get { box.state }
set { box.state = newValue }
}
}
Эта структура является единственной публично видимой частью стирателя типов. Она напрямую соответствует AnySequence, AnyIterator и т. д. в стандартной библиотеке. Обратите внимание, что структура является обобщенной только по State, а инициализатор является обобщенным по R: Restorable. Именно этот инициализатор обеспечивает связь между обобщенным параметром State и входным (и подлежащим стиранию) типом State протокола Restorable.
В общем, когда мы пишем стиратель типов, мы должны позаботиться о том, чтобы включить все методы, которые есть в протоколе. Хотя компилятор будет полезен, он не укажет, когда мы забываем включить метод, который является частью протокола, но имеет реализацию по умолчанию. В стирателе типов мы не должны полагаться на реализации по умолчанию, а вместо этого всегда должны вызывать реализацию основного типа, потому что она могла быть настроена.
Ручное стирание типов с разблокированными экзистенциями Link to heading
Хотя более мощные экзистенции в Swift 5.7 не сделают ручные обертки для стирания типов устаревшими, они открывают новый способ написания этих оберток, который более лаконичен и проще для оптимизации компилятором. Начнем с написания простой обертки AnyRestorable, которая теперь может хранить экзистенцию напрямую:
struct AnyRestorable<State: Codable> {
private var _value: any Restorable
init<R: Restorable>(_ value: R) where R.State == State {
self._value = value
}
}
Затем мы сталкиваемся с проблемой при попытке реализовать соответствие протоколу для Restorable. Поскольку мы стерли связь между нашим типом State и экзистенцией Restorable, нет простого способа реализовать требование протокола, которое ссылается на State. Хитрость заключается в том, чтобы написать расширение протокола, которое будет служить мостом между типизированным и стираемым типами:
private extension Restorable {
// Перенаправление на требование доступным способом через экзистенцию.
func _getStateThunk<_State>() -> _State {
assert(_State.self == State.self)
return unsafeBitCast(state, to: _State.self)
}
mutating func _setStateThunk<_State>(newValue: _State) {
assert(_State.self == State.self)
state = unsafeBitCast(newValue, to: State.self)
}
}
В общем, это расширение будет включать один метод для каждого из требований вашего протокола, которые не могут быть доступны напрямую в экзистенции. Нам пришлось разделить требование к изменяемому свойству на отдельные методы получения и установки, потому что методы должны быть обобщенными, а свойства не могут иметь обобщенные параметры. Теперь соответствие протоколу может перенаправлять на новые вспомогательные методы:
extension AnyRestorable: Restorable {
var state: State {
get { _value._getStateThunk() }
set { _value._setStateThunk(newValue: newValue) }
}
}
Этот умный кусок кода использует тот факт, что внутри расширения протокола ассоциированные типы протокола (и Self) доступны, и все API, которые на них ссылаются, могут быть вызваны. Приведение типов между State (ассоциированный тип) и _State (обобщенный параметр) безопасно, потому что инициализатор нашей обертки для стирания типов гарантирует, что участвуют только совместимые типы.
Резюме Link to heading
Протоколы Swift могут использоваться вместе с обобщениями для написания повторно используемого и расширяемого кода. Расширенные функции, такие как расширения протоколов, условная совместимость и ассоциированные типы, позволяют нам моделировать сложные интерфейсы. Если подумать, стандартная библиотека и некоторые другие библиотеки используют протоколы для целей, отличных от большинства разработчиков приложений. Иерархия протоколов Collection была тщательно спроектирована, чтобы предоставить абстракции, с которыми программисты могут писать значимые обобщенные алгоритмы. Каждый протокол должен оправдывать свое существование, (a) имея требования и семантику, которые позволяют создать новый класс алгоритмов, (b) хорошо сочетаясь с другими протоколами для возможности создания еще большего количества алгоритмов и (c) не будучи чрезмерно требовательным, чтобы позволить как можно большему количеству типов реализовать его. Но не каждый протокол должен быть таким. Нет ничего плохого в простом протоколе для абстрагирования зависимости, чтобы сделать ваш код более тестируемым.
И помните, что, как и любая абстракция, протоколы могут упрощать код, но они также могут иметь противоположный эффект: мы определенно видели (и писали) код, который стал очень трудным для понимания из-за чрезмерного использования протоколов. Во многих случаях код можно написать более простым способом, используя значения или функции. Найти правильный баланс требует некоторого опыта. При переписывании кода на основе протоколов в обычные функции явные свидетельства протоколов могут быть первым шагом.
Протоколы Коллеций Link to heading
11 Link to heading
Мы упоминали в главе о встроенных коллекциях, что коллекции Swift — такие как Array, Dictionary и Set — реализованы на основе богатого набора абстракций для обработки последовательностей элементов. Эта глава посвящена протоколам Sequence и Collection, которые являются основополагающими для этой модели. Мы рассмотрим, как работают эти протоколы, почему они работают именно так и как вы можете написать свои собственные последовательности и коллекции.
Чтобы лучше понять протоколы коллекций: → MutableCollection добавляет возможность изменять элемент через подскрипт за постоянное время. Он не позволяет добавлять или удалять элементы. Массив является MutableCollection, но, что примечательно, строка не является таковой, поскольку не может гарантировать изменение за постоянное время, так как символы не имеют фиксированной ширины. → RangeReplaceableCollection добавляет возможность заменять непрерывный диапазон элементов в коллекции. По расширению это также добавляет методы, такие как append, remove и так далее. Многие изменяемые коллекции также являются заменяемыми по диапазону, но есть исключения. Наиболее примечательно, что Set и Dictionary не соответствуют этому, но такие типы, как String и Array, соответствуют. → BidirectionalCollection добавляет возможность итерации в обратном направлении через коллекцию. Например, Dictionary не позволяет обратную итерацию и не соответствует этому, но String соответствует. Обратная итерация критически важна для некоторых алгоритмов. → RandomAccessCollection расширяет BidirectionalCollection и добавляет возможность более эффективного вычисления с индексами: он требует, чтобы измерение расстояния между индексами и перемещение индексов на определенное расстояние занимало постоянное время. Например, массив является коллекцией с произвольным доступом, но строка не является таковой, поскольку вычисление расстояния между двумя индексами строки занимает линейное время. → LazySequenceProtocol моделирует последовательность, которая вычисляет свои элементы лениво во время итерации. Это в основном полезно для написания алгоритмов в функциональном стиле: вы можете взять бесконечную последовательность и отфильтровать ее, а затем взять первый элемент, не понеся (бесконечных) затрат на вычисление элементов, которые последующий код не требует. → LazyCollectionProtocol является тем же, что и LazySequenceProtocol, но для коллекций. В этой главе мы подробно рассмотрим каждый из этих протоколов. Имейте в виду иерархию протоколов, когда вы пишете свои собственные алгоритмы коллекций: если вы можете написать алгоритм на одном из протоколов, приближающихся к корню иерархии, больше типов смогут воспользоваться этим алгоритмом.
Последовательности Link to heading
Протокол Sequence стоит в основе иерархии. Последовательность — это серия значений одного типа, которая позволяет вам итерироваться по этим значениям. Наиболее распространенный способ обхода последовательности — это цикл for:
for element in someSequence {
doSomething(with: element)
}
Эта, на первый взгляд, простая возможность перечисления элементов формирует основу для множества полезных операций, которые предоставляет протокол Sequence его пользователям. Мы уже видели многие из них в предыдущих главах. Каждый раз, когда вы сталкиваетесь с распространенной операцией, которая зависит от последовательного доступа к серии значений, вам следует рассмотреть возможность реализации ее на основе протокола Sequence.
Требования к протоколу Sequence довольно небольшие. Все, что должен сделать соответствующий тип, — это предоставить метод makeIterator(), который возвращает итератор:
protocol Sequence {
associatedtype Element
associatedtype Iterator: IteratorProtocol
func makeIterator() -> Iterator
// ...
}
Мы можем извлечь две вещи из этого (упрощенного) определения Sequence: у последовательности есть связанный тип Element, и она знает, как создать итератор. Давайте сначала более подробно рассмотрим итераторы.
Итераторы Link to heading
Последовательности предоставляют доступ к своим элементам, создавая итератор. Итератор выдает значения последовательности по одному за раз и отслеживает свое состояние итерации, проходя по последовательности. Единственный метод, определенный в IteratorProtocol, — это next(), который должен возвращать следующий элемент в последовательности при каждом последующем вызове или nil, когда последовательность исчерпана:
protocol IteratorProtocol {
associatedtype Element
mutating func next() -> Element?
}
Большинство протоколов не заканчиваются на Protocol, но есть несколько исключений в стандартной библиотеке: [Async]IteratorProtocol, StringProtocol, Keyed[En|De]CodingContainerProtocol и Lazy[Collection|Sequence]Protocol. Это сделано для того, чтобы избежать конфликтов имен с ассоциированными или конкретными типами, которые используют имена без суффиксов. Руководство по проектированию API предлагает, чтобы протоколы были либо существительными, либо имели суффиксы -able, -ible или -ing, в зависимости от роли протокола.
Ассоциированный тип Element указывает тип значений, которые производит итератор. Например, тип элемента итератора для String — это Character. По расширению итератор также определяет тип элемента своей последовательности. Это достигается через ограничение на ассоциированный тип Iterator последовательности — Iterator.Element == Element гарантирует, что оба типа элемента одинаковы:
protocol Sequence {
associatedtype Element
associatedtype Iterator: IteratorProtocol
where Iterator.Element == Element
// ...
}
Обычно вам нужно беспокоиться об итераторах только тогда, когда вы реализуете один для пользовательского типа последовательности. В противном случае вам редко нужно использовать итераторы напрямую, потому что цикл for является идиоматическим способом обхода последовательности. На самом деле, именно так работает цикл for под капотом: компилятор создает новый итератор для последовательности и многократно вызывает next на этом итераторе, пока не будет возвращен nil. Пример цикла for, который мы показали выше, по сути является сокращением для следующего:
var iterator = someSequence.makeIterator()
while let element = iterator.next() {
doSomething(with: element)
}
Итераторы являются конструкциями одноразового прохода; их можно только продвигать, и их нельзя никогда отменить или сбросить. Чтобы перезапустить итерацию, вы создаете новый итератор (на самом деле, именно это и позволяет сделать Sequence через makeIterator()). Хотя большинство итераторов будут производить конечное количество элементов и в конечном итоге вернут nil из next(), ничто не мешает вам выдавать бесконечную серию, которая никогда не заканчивается. На самом деле, самый простой итератор, который можно представить — за исключением того, который немедленно возвращает nil — это тот, который просто возвращает одно и то же значение снова и снова:
struct ConstantIterator: IteratorProtocol {
typealias Element = Int
mutating func next() -> Int? {
1
}
}
Явное определение typealias для Element является необязательным (но часто полезно для документирования, особенно в более крупных протоколах). Если мы опустим его, компилятор выведет конкретный тип Element из возвращаемого типа next():
struct ConstantIterator: IteratorProtocol {
mutating func next() -> Int? {
1
}
}
Обратите внимание, что метод next() объявлен как mutating. Это не строго необходимо в этом упрощенном примере, потому что наш итератор не имеет изменяемого состояния. На практике, однако, итераторы по своей природе имеют состояние. Почти любой полезный итератор требует изменяемого состояния, чтобы отслеживать свою позицию в последовательности.
Мы можем создать новый экземпляр ConstantIterator и пройтись по последовательности, которую он производит, в цикле while, печатая бесконечный поток единиц:
var iterator = ConstantIterator()
while let x = iterator.next() {
print(x)
}
Давайте рассмотрим более сложный пример. FibsIterator производит последовательность Фибоначчи. Он отслеживает текущее положение в последовательности, храня два предстоящих числа. Метод next затем возвращает первое число и обновляет состояние для следующего вызова. Как и в предыдущем примере, этот итератор также производит “бесконечный” поток; он продолжает генерировать числа, пока не достигнет переполнения целого числа, после чего программа завершится с ошибкой:
struct FibsIterator: IteratorProtocol {
var state = (0, 1)
mutating func next() -> Int? {
let upcomingNumber = state.0
state = (state.1, state.0 + state.1)
return upcomingNumber
}
}
Соответствие последовательности Link to heading
Полезным примером (конечной) последовательности будет последовательность всех узлов в HTML-документе. В главе о перечислениях мы определили перечисление для представления HTML-узлов:
enum Node: Hashable {
case text(String)
indirect case element(
name: String,
attributes: [String: String] = [:],
children: Node = .fragment([]))
case fragment([Node])
}
Вот пример узла Node (в главе о перечислениях мы также определили несколько вспомогательных методов для создания узлов, но здесь мы опустим их ради краткости):
let header: Node = .element(name: "h1", children: .fragment([
.text("Hello "),
.element(name: "em", children: .text("World"))
]))
Мы можем сделать наш тип Node соответствующим последовательности, создав пользовательский итератор. Внутри итератора мы будем отслеживать все “оставшиеся” узлы. Мы начнем с добавления корневого узла нашего HTML-дерева. Итератор всегда удаляет и возвращает первый элемент в массиве оставшихся узлов. Если у этого элемента есть какие-либо дочерние узлы, он добавит их к оставшимся узлам:
struct NodeIterator: IteratorProtocol {
var remaining: [Node]
mutating func next() -> Node? {
guard !remaining.isEmpty else { return nil }
let result = remaining.removeFirst()
switch result {
case .text(_):
break
case .element(name: _, attributes: _, children: let children):
remaining.append(children)
case .fragment(let elements):
remaining.append(contentsOf: elements)
}
return result
}
}
Это не единственный возможный способ итерации по дереву узлов. Этот итератор выполняет обход дерева в ширину, но вы также можете использовать любой другой алгоритм обхода дерева. Однако вы можете сделать Node соответствующим последовательности только один раз, поэтому вам нужно выбрать алгоритм, который имеет наибольший смысл.
Сделать Node соответствующим последовательности теперь так же просто, как создать NodeIterator:
extension Node: Sequence {
func makeIterator() -> NodeIterator {
NodeIterator(remaining: [self])
}
}
Просто соответствие последовательности делает множество полезных методов доступными для Node. Например, мы можем использовать contains(where:), чтобы проверить, содержит ли документ выделенные узлы:
header.contains(where: { node in
guard case .element(name: "em", _, _) = node else { return false }
return true
})
// true
Или мы можем использовать compactMap, чтобы извлечь весь текст из документа:
header.compactMap { node -> String? in
guard case let .text(t) = node else { return nil }
return t
}
// ["Hello ", "World"]
Существует множество других полезных операций: мы можем использовать инициализатор массива для создания массива всех узлов, allSatisfy, чтобы проверить, выполняется ли условие для каждого элемента, или просто цикл for, чтобы пройтись по всем узлам документа.
Мы можем создать последовательности для ConstantIterator и FibsIterator аналогичным образом. Мы не показываем их здесь, но вы можете попробовать это сами. Просто имейте в виду, что эти итераторы создают бесконечные последовательности. Используйте конструкцию вроде for i in FibsSequence.prefix(10), чтобы отрезать конечный кусок.
Итераторы и семантика значений Link to heading
Итераторы, которые мы видели до сих пор, все имеют семантику значений. Если вы создаете копию одного из них, все состояние итератора будет скопировано, и два экземпляра будут вести себя независимо друг от друга, как и ожидалось. Тем не менее, большинство итераторов в стандартной библиотеке также имеют семантику значений, но есть и исключения.
Чтобы проиллюстрировать разницу между семантикой значений и ссылочной семантикой, сначала рассмотрим StrideToIterator. Это базовый итератор для последовательности, которая возвращается из функции stride(from:to:by:). Давайте создадим StrideToIterator и вызовем next несколько раз:
// Последовательность от 0 до 9.
let seq = stride(from: 0, to: 10, by: 1)
var i1 = seq.makeIterator()
i1.next() // Optional(0)
i1.next() // Optional(1)
Теперь i1 готов вернуть 2. Теперь, скажем, вы создаете его копию:
var i2 = i1
Оригинал и копия теперь отдельны и независимы, и оба возвращают 2, когда вы вызываете next:
i1.next() // Optional(2)
i1.next() // Optional(3)
i2.next() // Optional(2)
i2.next() // Optional(3)
Это происходит потому, что StrideToIterator, довольно простая структура, чья реализация не слишком отличается от нашей реализации итератора Фибоначчи выше, имеет семантику значений.
Теперь давайте посмотрим на итератор, который не имеет семантики значений. AnyIterator — это итератор, который оборачивает другой итератор, тем самым «стирая» конкретный тип базового итератора. Пример, когда это может быть полезно, — если вы хотите скрыть конкретный тип сложного итератора, который мог бы раскрыть детали реализации в вашем публичном API. Способ, которым AnyIterator делает это, заключается в оборачивании базового итератора в внутренний объект-ящик, который является типом ссылки. (Если вы хотите узнать, как это работает, ознакомьтесь с разделом о стирании типов в главе о протоколах.)
Чтобы увидеть, почему это важно, мы создаем AnyIterator, который оборачивает i1, а затем создаем копию:
var i3 = AnyIterator(i1)
var i4 = i3
В этой ситуации оригинал и копия не независимы, потому что, несмотря на то, что AnyIterator является структурой, он не имеет семантики значений. Объект-ящик, который AnyIterator использует для хранения своего базового итератора, является экземпляром класса, и когда мы присвоили i3 значение i4, была скопирована только ссылка на ящик. Хранение ящика разделяется между двумя итераторами. Любые вызовы next на i3 или i4 теперь увеличивают один и тот же базовый итератор:
i3.next() // Optional(4)
i4.next() // Optional(5)
i3.next() // Optional(6)
Очевидно, это может привести к ошибкам, хотя, скорее всего, вы редко столкнетесь с этой конкретной проблемой на практике, так как итераторы обычно не являются тем, что вы передаете в своем коде. Вы гораздо более вероятно создадите один локально — иногда явно, но в основном неявно через цикл for — используете его один раз для перебора элементов, а затем выбрасываете. Если вы обнаружите, что делитесь итераторами с другими объектами, рассмотрите возможность обернуть итератор в последовательность вместо этого.
Итераторы и последовательности на основе функций Link to heading
AnyIterator имеет второй инициализатор, который принимает функцию next непосредственно в качестве аргумента. В сочетании с соответствующим типом AnySequence это позволяет нам создавать итераторы и последовательности без определения новых типов. Например, мы могли бы определить итератор Фибоначчи как функцию, которая возвращает AnyIterator:
func fibsIterator() -> AnyIterator<Int> {
var state = (0, 1)
return AnyIterator {
let upcomingNumber = state.0
state = (state.1, state.0 + state.1)
return upcomingNumber
}
}
Держая переменную state вне замыкания next итератора и захватывая её внутри замыкания, мы можем изменять состояние каждый раз, когда оно вызывается. Существует только одно функциональное различие между двумя итераторами Фибоначчи: определение с использованием пользовательской структуры имеет семантику значений, а определение с использованием AnyIterator — нет.
Создание последовательности из этого теперь еще проще, потому что AnySequence предоставляет инициализатор, который принимает функцию, производящую итератор:
let fibsSequence = AnySequence(fibsIterator)
Array(fibsSequence.prefix(10)) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Другой альтернативой является использование функции sequence, которая имеет два варианта. Первый, sequence(first:next:), возвращает последовательность, первый элемент которой — это первый аргумент, который вы передали; последующие элементы производятся функцией, переданной в аргумент next. Другой вариант, sequence(state:next:), еще более мощный, потому что он может сохранять произвольное изменяемое состояние между вызовами функции next. Мы можем использовать это для построения последовательности Фибоначчи с помощью одного вызова функции:
let fibsSequence2 = sequence(state: (0, 1)) { state -> Int? in
let upcomingNumber = state.0
state = (state.1, state.0 + state.1)
return upcomingNumber
}
Array(fibsSequence2.prefix(10)) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Тип возвращаемого значения sequence(first:next:) и sequence(state:next:) — это UnfoldSequence. Этот термин происходит из функционального программирования, где та же операция часто называется unfold. Функция sequence является естественным аналогом reduce (которая часто называется fold в функциональных языках). В то время как reduce уменьшает (или сворачивает) последовательность в одно возвращаемое значение, sequence расширяет (или разворачивает) одно значение, чтобы сгенерировать последовательность.
Хотя AnyIterator часто кажется более дружелюбным, чем сложный тип итератора с длинным именем, стандартная библиотека предпочитает пользовательские типы итераторов по причинам производительности. AnyIterator значительно усложняет компилятору оптимизацию кода, что может привести к потере производительности до 100 раз. Бен подробно писал об этом в посте на Swift Forums.
Как и все итераторы, которые мы видели до сих пор, функции sequence применяют свои функции next лениво, т.е. следующее значение не вычисляется, пока не будет запрошено вызывающим кодом. Это позволяет конструкциям, таким как fibsSequence2.prefix(10), работать. prefix(10) только запрашивает у последовательности её первые (до) 10 элементов и затем останавливается. Если бы последовательность попыталась вычислить все свои значения жадно, программа бы завершилась с переполнением целого числа до того, как следующий шаг имел бы шанс выполниться.
Возможность создания бесконечных последовательностей — это то, что отличает последовательности от коллекций, которые не могут быть бесконечными.
Однопроходные последовательности Link to heading
Последовательности не ограничиваются классическими структурами данных, такими как массивы или списки. Сетевые потоки, файлы на диске, потоки событий пользовательского интерфейса и многие другие виды данных могут быть смоделированы как последовательности. Однако не все из них ведут себя как массив при многократной итерации по элементам.
В то время как последовательность Фибоначчи не затрагивается при обходе её элементов (последующий обход начинается снова с нуля), последовательность, представляющая поток сетевых пакетов, является однопроходной; она не будет производить те же значения снова, если вы начнете новую итерацию. Тем не менее, обе являются допустимыми последовательностями, поэтому документация очень четко указывает, что Sequence не дает никаких гарантий относительно многократных обходов:
Протокол Sequence не накладывает требований на соответствующие типы относительно того, будут ли они разрушительно потребляться при итерации. В результате не следует предполагать, что множественные циклы for-in по последовательности либо возобновят итерацию, либо начнут с начала:
for element in sequence {
if ... some condition { break }
}
for element in sequence {
// Неопределенное поведение
}
Соответствующая последовательность, которая не является коллекцией, может производить произвольную последовательность элементов во втором цикле for-in. Это также объясняет, почему, казалось бы, тривиальное свойство property доступно только для коллекций, а не для последовательностей. Вызов геттера свойства должен быть свободен от побочных эффектов, и только протокол Collection гарантирует безопасную многопроходную итерацию.
В качестве примера однопроходной последовательности рассмотрим этот обертку вокруг функции readLine, которая считывает строки из стандартного ввода:
let standardIn = AnySequence {
AnyIterator {
readLine()
}
}
Теперь вы можете использовать эту последовательность с различными расширениями Sequence. Например, вы можете написать версию утилиты Unix cat, которая нумерует строки:
let numberedStdIn = standardIn.enumerated()
for (i, line) in numberedStdIn {
print("\(i+1): \(line)")
}
Метод enumerated оборачивает последовательность в новую последовательность, которая производит пары элементов оригинальной последовательности и увеличивающиеся числа, начиная с нуля. Как и в нашей обертке readLine, элементы генерируются лениво. Потребление базовой последовательности происходит только тогда, когда вы перемещаетесь по перечисленной последовательности, используя её итератор, а не когда она создается. Поэтому, если вы запустите приведенный выше код из командной строки, вы увидите, что он ждет внутри цикла for. Он печатает строки, которые вы вводите, когда нажимаете клавишу возврата; он не ждет, пока ввод не будет завершен с помощью Control-D. Тем не менее, каждый раз, когда enumerated предоставляет строку из standardIn, он потребляет стандартный ввод. Вы не можете итерировать по нему дважды, чтобы получить те же результаты.
Как автор расширения Sequence, вы не обязаны учитывать, является ли последовательность однопроходной. Однако, если возможно, постарайтесь написать свой алгоритм, используя один проход. Как вызывающий метод на типе последовательности, вы определенно должны помнить, является ли последовательность однопроходной.
Определенным признаком того, что последовательность является многопроходной, является то, что она также соответствует Collection, поскольку этот протокол дает такую гарантию. Обратное неверно. Даже стандартная библиотека имеет некоторые последовательности, которые могут быть безопасно пройдены несколько раз, хотя они и не являются коллекциями. Примеры включают типы StrideTo и StrideThrough, возвращаемые функциями stride(from:to:by:) и stride(from:through:by:).
Связь между последовательностями и итераторами Link to heading
Последовательности и итераторы настолько похожи, что вы можете задаться вопросом, зачем эти типы нужно разделять. Разве мы не можем просто объединить функциональность протокола IteratorProtocol с Sequence? Это действительно будет работать хорошо для последовательностей с одним проходом, таких как наш стандартный пример ввода. Последовательности такого рода несут собственное состояние итерации и изменяются по мере их обхода.
Многоразовые последовательности, такие как массивы или наша последовательность Фибоначчи, не должны изменяться в процессе цикла for; они требуют отдельного состояния обхода, и именно это предоставляет итератор (вместе с логикой обхода, но она также может находиться в последовательности). Цель метода makeIterator заключается в создании этого состояния обхода.
Каждый итератор также может рассматриваться как последовательность с одним проходом по элементам, которые он еще должен вернуть. На самом деле, вы можете превратить каждый итератор в последовательность, просто объявив соответствие; Sequence поставляется с реализацией по умолчанию для makeIterator, которая возвращает self, если соответствующий тип является итератором:
extension Sequence where Iterator == Self {
func makeIterator() -> Self {
return self
}
}
Большинство итераторов в стандартной библиотеке соответствуют Sequence.
Коллекции Link to heading
Коллекция — это последовательность с несколькими проходами, которую можно обходить недеструктивно несколько раз. Элементы коллекции могут быть не только пройдены линейно, но также могут быть доступны по индексу с помощью подскрипта. Индексы коллекций часто являются целыми числами, как в массивах. Но, как мы увидим, индексы также могут быть непрозрачными значениями (как в словарях или строках), что иногда делает работу с ними неинтуитивной. Индексы коллекции неизменно формируют конечный диапазон с определенным началом и концом. Это означает, что, в отличие от последовательностей, коллекции не могут быть бесконечными. Каждая коллекция также имеет связанную подпоследовательность, которая представляет собой непрерывный срез коллекции.
Протокол Collection строится на основе Sequence. В дополнение ко всем методам, унаследованным от Sequence, коллекции имеют дополнительные возможности, которые либо зависят от доступа к элементам в определенных позициях, либо полагаются на гарантию многопроходной итерации, такие как свойство count (если бы подсчет элементов однопроходной последовательности потреблял последовательность, это как бы противоречило бы цели).
Даже если ваш пользовательский тип последовательности не нуждается в специальных функциях коллекции, вы можете использовать соответствие протоколу Collection, чтобы сигнализировать пользователям, что ваш тип конечен и поддерживает многопроходную итерацию. Это несколько странно, что вам нужно придумать индекс, если все, что вы хотите, — это задокументировать, что ваша последовательность является многопроходной, особенно если учесть, что выбор подходящего типа индекса часто является самой сложной частью реализации протокола Collection. Одна из причин этого дизайна заключается в том, что команда Swift хотела избежать потенциальной путаницы, связанной с наличием отдельного протокола для многопроходных последовательностей, который имел бы требования, идентичные Sequence, но другую семантику.
Коллекции широко используются в стандартной библиотеке. В дополнение к Array, Dictionary и Set, String и его представления также являются коллекциями, как и [Closed]Range (условно) и UnsafeBufferPointer. За пределами стандартной библиотеки тип Data из Foundation является одним из наиболее часто используемых типов коллекций. А пакет Swift Collections предоставляет дополнительные структуры данных, такие как упорядоченные множества и словари.
A Custom Collection Link to heading
Чтобы продемонстрировать, как работают коллекции в Swift, мы реализуем одну из своих. Вероятно, самый полезный тип контейнера, отсутствующий в стандартной библиотеке Swift, — это очередь (хотя в пакете Swift Collections доступна двусторонняя очередь). Массивы Swift можно легко использовать как стеки, с помощью метода append для добавления и popLast для удаления. Однако их не очень удобно использовать как очереди. Вы можете использовать push в сочетании с remove(at: 0), но удаление первого элемента массива — это операция O(n), потому что массивы хранятся в смежной памяти, и каждый элемент должен быть сдвинут, чтобы заполнить пробел (в отличие от удаления последнего элемента, которое можно выполнить за постоянное время). Ниже представлена простая очередь FIFO (первый пришел — первый вышел) с реализованными только методами enqueue и dequeue на основе двух массивов:
/// Эффективная очередь FIFO переменного размера для элементов типа `Element`.
struct FIFOQueue<Element> {
private var left: [Element] = []
private var right: [Element] = []
/// Добавляет элемент в конец очереди.
/// - Сложность: O(1).
mutating func enqueue(_ newElement: Element) {
right.append(newElement)
}
/// Удаляет элемент из начала очереди.
/// Возвращает `nil`, если очередь пуста.
/// - Сложность: амортизированная O(1).
mutating func dequeue() -> Element? {
if left.isEmpty {
left = right.reversed()
right.removeAll()
}
return left.popLast()
}
}
Эта реализация использует технику имитации очереди с помощью двух стеков (двух обычных массивов). Когда элементы добавляются в очередь, они помещаются в “правый” стек. Затем, когда элементы извлекаются из очереди, они извлекаются из “левого” стека, где они хранятся в обратном порядке. Когда левый стек пуст, правый стек переворачивается и помещается в левый стек.
Вы можете найти утверждение о том, что операция dequeue имеет сложность O(1) немного удивительным. Разве в ней нет вызова reversed, который имеет сложность O(n)? Но хотя это и правда, общая амортизированная временная сложность для извлечения элемента постоянна — при большом количестве операций добавления и извлечения общее время для них остается постоянным, даже если время для отдельных добавлений или извлечений может быть иным.
Ключ к пониманию этого заключается в том, как часто происходит переворот и сколько элементов. Одна из техник для анализа этого — это “методология банкира”. Представьте, что каждый раз, когда вы добавляете элемент в очередь, вы вносите токен в банк. Одно добавление — один токен, так что это постоянная стоимость. Затем, когда приходит время перевернуть правый стек в левый, у вас есть токен в банке для каждого добавленного элемента, и вы используете эти токены, чтобы оплатить переворот. Счет никогда не уходит в дебет, так что вы никогда не тратите больше, чем заплатили.
Такой подход хорошо объясняет, почему “амортизированная” стоимость операции со временем постоянна, даже если отдельные вызовы могут быть иными. Тот же подход можно использовать, чтобы объяснить, почему добавление элемента в массив в Swift является (амортизированной) операцией постоянного времени. Когда массив исчерпывает память, ему нужно выделить больше памяти и скопировать все существующие элементы в новое хранилище. Но поскольку размер хранилища увеличивается экспоненциально, вы можете использовать тот же аргумент “добавить элемент, заплатить токен, удвоить размер массива, потратить все токены, но не больше”.
Теперь у нас есть контейнер, который может добавлять и извлекать элементы. Следующий шаг — добавить соответствие протоколу Collection для FIFOQueue. К сожалению, выяснить минимальный набор реализаций, которые необходимо предоставить для соответствия протоколу, иногда может быть утомительным занятием в Swift.
На момент написания протокол Collection имеет целых пять связанных типов, пять свойств, шесть методов экземпляра и два сабскрипта:
protocol Collection: Sequence {
associatedtype Element // Унаследовано от Sequence.
associatedtype Index: Comparable
// где ... условие опущено.
var startIndex: Index { get }
var endIndex: Index { get }
associatedtype Iterator = IndexingIterator<Self>
associatedtype SubSequence: Collection = Slice<Self>
where Element == SubSequence.Element,
SubSequence == SubSequence.SubSequence
subscript(position: Index) -> Element { get }
subscript(bounds: Range<Index>) -> SubSequence { get }
associatedtype Indices: Collection = DefaultIndices<Self> where Indices == Indices.SubSequence
var indices: Indices { get }
var isEmpty: Bool { get }
var count: Int { get }
func makeIterator() -> Iterator // Унаследовано от Sequence.
func index(_ i: Index, offsetBy distance: Int) -> Index
func index(_ i: Index, offsetBy distance: Int, limitedBy limit: Index) -> Index?
func distance(from start: Index, to end: Index) -> Int
func index(after i: Index) -> Index
func formIndex(after i: inout Index)
}
Связанный тип SubSequence использует рекурсивное ограничение, чтобы указать, что SubSequence также должен быть коллекцией. Это гарантирует, что элементы SubSequence имеют тот же тип, что и в коллекции, и что SubSequence имеет такой же тип, как и его SubSequence. Например, String имеет Substring в качестве своего SubSequence, а Substring имеет сам себя в качестве своего SubSequence.
Связанный тип Indices также является коллекцией. Обратите внимание, что мы опустили длинный список ограничений на Index ради краткости. Вкратце, они гарантируют, что Index является как элементом, так и индексом коллекции Indices, а также индексами SubSequence и Indices.
С учетом всех вышеуказанных требований, соответствие протоколу Collection кажется сложной задачей. Однако на самом деле это не так уж и плохо. Обратите внимание, что все связанные типы, кроме Index и Element, имеют значения по умолчанию, поэтому вам не нужно беспокоиться об этом, если ваш тип коллекции не имеет особых требований. То же самое касается большинства методов, свойств и сабскриптов: расширения протокола Collection предоставляют реализации по умолчанию. Некоторые из этих расширений имеют ограничения на связанные типы, которые соответствуют значениям по умолчанию протокола; например, Collection предоставляет реализацию по умолчанию для метода makeIterator, только если его Iterator является IndexingIterator<Self>:
extension Collection where Iterator == IndexingIterator<Self> {
func makeIterator() -> IndexingIterator<Self>
}
Если вы решите, что ваш тип должен иметь другой тип итератора, вам придется реализовать метод выше.
Выяснить, что требуется и что предоставляется по умолчанию, не так уж сложно, но это много ручной работы, и если вы не будете осторожны, чтобы ничего не упустить, легко оказаться в раздражающей игре с угадыванием с компилятором. Самая разочаровывающая часть процесса может заключаться в том, что компилятор имеет всю информацию, чтобы направить вас; диагностика просто не очень полезна.
На данный момент ваша лучшая надежда — найти минимальные требования к соответствию, изложенные в документации, как это действительно имеет место для Collection:
… Чтобы добавить соответствие Collection к вашему типу, вы должны объявить как минимум следующие требования:
→ Свойства startIndex и endIndex.
→ Сабскрипт, который предоставляет как минимум доступ только для чтения к элементам вашего типа.
→ Метод index(after:) для продвижения индекса в вашей коллекции.
Таким образом, в конечном итоге мы получаем следующие требования:
protocol Collection: Sequence {
/// Тип, представляющий элементы последовательности.
associatedtype Element
/// Тип, представляющий позицию в коллекции.
associatedtype Index: Comparable
/// Позиция первого элемента в непустой коллекции.
var startIndex: Index { get }
/// Позиция "после конца" коллекции — то есть позиция, которая на
/// единицу больше последнего допустимого аргумента сабскрипта.
var endIndex: Index { get }
/// Возвращает позицию сразу после данного индекса.
func index(after i: Index) -> Index
/// Получает элемент по указанной позиции.
subscript(position: Index) -> Element { get }
}
Мы можем сделать FIFOQueue соответствующим Collection следующим образом:
extension FIFOQueue: Collection {
public var startIndex: Int { return 0 }
public var endIndex: Int { return left.count + right.count }
public func index(after i: Int) -> Int {
precondition(i >= startIndex && i < endIndex, "Индекс вне границ")
return i + 1
}
public subscript(position: Int) -> Element {
precondition((startIndex..<endIndex).contains(position), "Индекс вне границ")
if position < left.endIndex {
return left[left.endIndex - position - 1]
} else {
return right[position - left.endIndex]
}
}
}
Мы используем Int в качестве типа индекса для нашей очереди. Мы не указываем явный typealias для связанного типа; так же, как и с Element, Swift может вывести его из определений методов и свойств. Обратите внимание, что поскольку индексация возвращает элементы сначала из начала, FIFOQueue сначала возвращает следующий элемент, который будет извлечен (так что это служит своего рода “подсмотром”).
С помощью всего лишь нескольких строк, очереди теперь имеют более 40 методов и свойств в своем распоряжении. Например, мы можем итерировать по очередям:
var queue = FIFOQueue<String>()
for x in ["1", "2", "foo", "3"] {
queue.enqueue(x)
}
for s in queue {
print(s, terminатор: " ")
} // 1 2 foo 3
Мы можем передавать очереди методам, которые принимают последовательности:
var a = Array(queue) // ["1", "2", "foo", "3"]
a.append(contentsOf: queue[2...3])
a // ["1", "2", "foo", "3", "foo", "3"]
Мы можем вызывать методы и свойства, которые расширяют Sequence:
queue.map { $0.uppercased() } // ["1", "2", "FOO", "3"]
queue.compactMap { Int($0) } // [1, 2, 3]
queue.filter { $0.count > 1 } // ["foo"]
queue.sorted() // ["1", "2", "3", "foo"]
queue.joined(separator: " ") // 1 2 foo 3
И мы можем вызывать методы и свойства, которые расширяют Collection:
queue.isEmpty // false
queue.count // 4
queue.first // Optional("1")
Array Literals Link to heading
При написании коллекции, такой как FIFOQueue, приятно реализовать также протокол ExpressibleByArrayLiteral. Это позволит пользователям создавать очередь, используя знакомый синтаксис [value1, value2 и т.д.]. Протокол требует от нас реализации инициализатора, как показано ниже:
extension FIFOQueue: ExpressibleByArrayLiteral {
public init (arrayLiteral elements: Element...) {
self .init(left: elements.reversed(), right: [])
}
}
Для нашей логики очереди мы хотим обратить элементы, чтобы они были готовы к использованию в левом буфере. Конечно, мы могли бы просто скопировать элементы в правый буфер, но поскольку мы собираемся копировать элементы в любом случае, более эффективно скопировать их в обратном порядке, чтобы их не нужно было разворачивать позже, когда они будут извлечены из очереди.
Теперь очереди можно легко создавать из литералов:
let queue2: FIFOQueue = [1,2,3] // FIFOQueue<Int>(left: [3, 2, 1], right: [])
Важно подчеркнуть разницу между литералами и типами в Swift. [1,2,3] не является массивом. Скорее, это массивный литерал — нечто, что может быть использовано для создания любого типа, который соответствует протоколу ExpressibleByArrayLiteral. Этот конкретный литерал содержит другие литералы — целочисленные литералы — которые могут создавать любой тип, соответствующий протоколу ExpressibleByIntegerLiteral.
Эти литералы имеют “умолчательные” типы — типы, которые Swift будет предполагать, если вы не укажете явный тип при использовании литерала. Таким образом, массивные литералы по умолчанию имеют тип Array, целочисленные литералы по умолчанию имеют тип Int, литералы с плавающей запятой по умолчанию имеют тип Double, а строковые литералы по умолчанию имеют тип String. Но это происходит только в отсутствие явного указания типа. Например, очередь, объявленная выше, является очередью целых чисел, но это могла бы быть очередь другого типа целых чисел:
let byteQueue: FIFOQueue<UInt8> = [1,2,3]
// FIFOQueue<UInt8>(left: [3, 2, 1], right: [])
Часто тип литерала может быть выведен из контекста. Например, вот как это выглядит, если функция принимает тип, который может быть создан из литералов:
func takesSetOfFloats( floats: Set<Float>) {
//...
}
takesSetOfFloats( floats: [1,2,3])
Этот литерал будет интерпретироваться как Set, а не как Array.
Ассоциированные Типы Link to heading
Мы видели, что Collection предоставляет значения по умолчанию для всех, кроме двух своих ассоциированных типов; типы, принимающие протокол, должны указать только тип Element и тип Index. Хотя вам не нужно слишком беспокоиться о других ассоциированных типах, будет полезно кратко рассмотреть каждый из них, чтобы лучше понять их конкретные цели. Давайте пройдемся по ним один за другим.
Iterator — Унаследован от Sequence. Мы уже подробно рассматривали итераторы в нашем обсуждении последовательностей. Тип итератора по умолчанию для коллекций — IndexingIterator<Self>. Это простая структура, которая оборачивает коллекцию и использует собственные индексы коллекции для перебора каждого элемента. Большинство коллекций в стандартной библиотеке используют IndexingIterator в качестве своего итератора. Должно быть немного причин для написания собственного типа итератора для пользовательской коллекции.
SubSequence — Тип непрерывного среза элементов коллекции. Подпоследовательности сами по себе являются коллекциями. Тип SubSequence по умолчанию — Slice<Self>, который оборачивает оригинальную коллекцию и хранит начальный и конечный индексы среза в терминах базовой коллекции. Имеет смысл для коллекции настраивать свой тип SubSequence, особенно если он может быть Self (т.е. срез коллекции имеет тот же тип, что и сама коллекция). Data из Foundation является примером такой коллекции. Мы поговорим больше о подпоследовательностях позже в этой главе.
Indices — Тип возвращаемого значения свойства indices коллекции. Он представляет собой коллекцию, содержащую все индексы, которые действительны для индексирования базовой коллекции, в порядке возрастания. Обратите внимание, что endIndex не включен, потому что он обозначает позицию “после конца” и, следовательно, не является допустимым аргументом для индексирования. Тип по умолчанию имеет оригинальное название DefaultIndices<Self>. Как и Slice, это простая обертка для базовой коллекции и начального и конечного индексов — ей необходимо хранить ссылку на базовую коллекцию, чтобы иметь возможность продвигать индексы. Это может привести к неожиданным проблемам с производительностью, если пользователи изменяют коллекцию во время итерации по ее индексам: если коллекция реализована с использованием copy-on-write (как все коллекции в стандартной библиотеке), дополнительная ссылка на коллекцию может вызвать ненужные копии. Если ваша пользовательская коллекция может предоставить альтернативный тип Indices, который не требует хранения ссылки на базовую коллекцию, это будет полезной оптимизацией. Это верно для всех коллекций, чья логика индексации не зависит от самой коллекции, таких как массивы или наша очередь. Если ваш индекс является целочисленным типом, вы можете использовать Range<Index>:
extension FIFOQueue: Collection {
// ...
typealias Indices = Range<Int>
var indices: Range<Int> {
startIndex..<endIndex
}
}
Индексы Link to heading
Индекс представляет собой позицию в коллекции. Каждая коллекция имеет два специальных индекса: startIndex и endIndex. startIndex обозначает первый элемент коллекции, а endIndex — индекс, который следует за последним элементом. В результате endIndex не является допустимым индексом для индексирования; вы используете его для формирования открытых диапазонов индексов (someIndex..<endIndex) или для сравнения других индексов с ним, например, в качестве условия выхода из цикла (while someIndex < endIndex).
До этого момента мы использовали целые числа в качестве индексов для наших коллекций. Массив делает это, и (с небольшими манипуляциями) наш тип FIFOQueue тоже. Целочисленные индексы интуитивны, но они не единственный вариант. Единственное требование к индексу коллекции — это то, что он должен быть Comparable, что является другим способом сказать, что индексы имеют определенный порядок.
Возьмем, к примеру, Dictionary. Кажется, что естественными кандидатами для индексов словаря будут его ключи; в конце концов, ключи — это то, что мы используем для обращения к значениям в словаре. Но ключ не может быть индексом, потому что вы не можете его продвигать — нет способа узнать, какой будет следующий индекс после данного ключа. Кроме того, индексирование с помощью индекса ожидается, чтобы давать прямой доступ к элементу, без обходов для поиска или хеширования.
Соответственно, Dictionary.Index является непрозрачным значением, которое указывает на позицию во внутреннем буфере хранения словаря. На самом деле это просто обертка для одного смещения типа Int, но это деталь реализации, не представляющая интереса для пользователей коллекции. (На самом деле реальность несколько сложнее. Индекс также включает счетчик мутаций, который позволяет словарю обнаруживать, когда к нему обращаются с недопустимым индексом. Кроме того, словари, которые передаются в или возвращаются из Objective-C API, используют NSDictionary в качестве своего хранилища для эффективного связывания, и тип индекса для этих словарей отличается. Но вы поняли идею.)
Это также объясняет, почему индексирование Dictionary с помощью индекса не возвращает опциональное значение, в то время как индексирование с помощью ключа — да. Подписка subscript(_ key: Key) — это дополнительная перегрузка оператора подстановки, которая определена непосредственно в Dictionary. Она возвращает опциональное значение Value:
struct Dictionary {
...
subscript(key: Key) -> Value?
}
В отличие от этого, индексирование с помощью индекса является частью протокола Collection и всегда возвращает не опциональное значение, потому что обращение к коллекции с недопустимым индексом (например, индексом вне границ массива) считается ошибкой программиста, и такое действие должно вызывать исключение:
protocol Collection {
subscript(position: Index) -> Element { get }
}
Обратите внимание на возвращаемый тип Element. Тип элемента словаря — это кортеж (key: Key, value: Value), поэтому для Dictionary этот подскрипт возвращает пару ключ-значение, а не просто Value. Это также объясняет, почему итерация по словарю в цикле for производит пары ключ-значение.
В разделе о индексировании массивов в главе о встроенных коллекциях мы обсуждали, почему имеет смысл даже для “безопасного” языка, такого как Swift, не оборачивать каждую операцию, которая может завершиться неудачей, в опциональную или конструкцию ошибки. “Если каждый API может потерпеть неудачу, то вы не сможете написать полезный код. Вам нужно иметь какую-то фундаментальную основу, на которую вы можете полагаться и доверять, что она будет работать правильно”, в противном случае ваш код будет застревать в проверках безопасности.
Недействительность индексов Link to heading
Индексы могут стать недействительными, когда коллекция изменяется. Недействительность может означать, что индекс остается действительным, но теперь указывает на другой элемент, или что индекс больше не является действительным для коллекции, и использование его для доступа к коллекции приведет к ошибке. Это должно быть интуитивно понятно, если рассмотреть массивы. Когда вы добавляете элемент, все существующие индексы остаются действительными. Когда вы удаляете первый элемент, существующий индекс последнего элемента становится недействительным. В то же время меньшие индексы остаются “действительными”, но элементы, на которые они указывают, изменились.
Индекс словаря остается стабильным, когда добавляются новые пары ключ-значение, пока словарь не вырастет настолько, что это вызовет перераспределение. Это происходит потому, что, когда элементы вставляются, местоположение индексируемого элемента в буфере хранения словаря не меняется, пока буфер не придется изменять размер, что заставляет все элементы быть повторно хешированными. Удаление элементов из словаря делает индексы недействительными.
Индекс должен быть чистым значением, которое хранит минимальное количество информации, необходимой для описания позиции элемента. В частности, индексы не должны хранить ссылку на свою коллекцию, если это вообще возможно, потому что это мешает оптимизациям “копирования при записи”, когда коллекция изменяется в цикле. Аналогично, коллекция обычно не может отличить один из своих “собственных” индексов от индекса, который пришел из другой коллекции того же типа. Снова это тривиально очевидно для массивов. Конечно, вы можете использовать целочисленный индекс, который был получен из одного массива, чтобы индексировать другой:
let numbers = [1, 2, 3, 4]
let squares = numbers.map { $0 * $0 }
let numbersIndex = numbers.firstIndex(of: 4)! // 3
squares[numbersIndex] // 16
Это также работает с непрозрачными типами индексов, такими как String.Index. В этом примере мы используем startIndex одной строки, чтобы получить доступ к первому символу другой строки:
let hello = "Hello"
let world = "World"
let helloIdx = hello.startIndex
world[helloIdx] // W
Однако тот факт, что вы можете это сделать, не означает, что это вообще хорошая идея. Если бы мы использовали индекс для обращения к пустой строке, программа бы выдала ошибку, потому что индекс был вне границ. Кроме того, поскольку символы имеют переменную ширину, индекс к n-му символу в одной строке может не указывать на допустимую границу символа в другой строке. Мы подробно обсуждали это в главе о строках.
Тем не менее, существуют законные случаи для совместного использования индексов между коллекциями. Самый важный из них — работа с срезами. Протокол Collection требует, чтобы индекс базовой коллекции указывал на один и тот же элемент в срезе этой коллекции, поэтому всегда безопасно делиться индексами с срезами.
Advancing Indices Link to heading
Swift 3 ввел значительное изменение в способ обработки обхода индексов для коллекций. Задача продвижения индекса вперед или назад (т.е. получение нового индекса из данного) теперь является ответственностью коллекции, в то время как до Swift 2 индексы могли продвигаться самостоятельно. Если раньше вы писали someIndex.successor(), чтобы перейти к следующему индексу, то теперь вы пишете collection.index(after: someIndex).
Почему команда Swift решила внести это изменение? Вкратце, из-за производительности. Оказалось, что получение индекса из другого часто требует информации о внутреннем устройстве коллекции. Это не требуется для массивов, где продвижение индекса является простой операцией сложения. Но индекс строки, например, должен проверять фактические данные символов, поскольку символы имеют переменные размеры в Swift.
В старой модели самопродвигающихся индексов это означало, что индекс должен был хранить ссылку на хранилище коллекции. Эта дополнительная ссылка была достаточной, чтобы подорвать оптимизации копирования при записи, используемые стандартными библиотечными коллекциями, и приводила бы к ненужным копиям, когда коллекция изменялась во время итерации.
Позволяя индексам оставаться чистыми значениями, новая модель не имеет этой проблемы. Она также концептуально легче для понимания и может упростить реализацию пользовательских типов индексов. В большинстве случаев индекс может быть представлен одним или двумя целыми числами, которые эффективно кодируют позицию элемента в базовом хранилище коллекции. Недостатком новой модели индексации является более многословный синтаксис.
CustomCollectionIndices Link to heading
В качестве примера коллекции с нецелочисленными индексами мы создадим способ итерации по словам в строке. Когда вы хотите разбить строку на слова, самый простой способ — использовать метод split(separator:maxSplits:omittingEmptySubsequences:). Этот метод определен в Collection и преобразует коллекцию в массив SubSequence, разбитый по указанному разделителю:
var str = "Still I see monsters"
str.split(separator: " ") // ["Still", "I", "see", "monsters"]
Каждое слово в возвращаемом массиве имеет тип Substring, который является ассоциированным типом SubSequence для String. Всякий раз, когда вам нужно разбить коллекцию, метод split почти всегда является правильным инструментом. Однако у него есть один недостаток: он жадно вычисляет весь массив. Если у вас есть большая строка и вам нужно только первые несколько слов, это не очень эффективно. Чтобы быть более эффективными, мы создадим коллекцию Words, которая не вычисляет все слова заранее, а вместо этого позволяет нам лениво итерироваться. (Пакет Swift Algorithms предоставляет ленивый вариант split, среди многих других полезных алгоритмов коллекций. Вам следует использовать его вместо нашей версии в производственном коде.)
Давайте начнем с нахождения диапазона первого слова в Substring. Мы будем использовать пробелы в качестве границ слов, хотя это интересное упражнение — сделать это настраиваемым. Подстрока может начинаться с произвольного количества пробелов, которые мы пропускаем. start — это подстрока с удаленными ведущими пробелами. Затем мы пытаемся найти следующий пробел; если есть пробел, мы используем его как конец границы слова. Если мы не можем найти больше пробелов, мы используем endIndex:
extension Substring {
var nextWordRange: Range<Index> {
let start = drop(while: { $0 == " " })
let end = start.firstIndex(where: { $0 == " " }) ?? endIndex
return start.startIndex..<end
}
}
Обратите внимание, что Range является полузакрытым: верхняя граница, end, не включена в диапазон слова. Логичным первым выбором для типа индекса коллекции Words было бы Int: индекс i означал бы i-е слово в коллекции. Однако доступ к элементу через индексный сабскрипт должен быть операцией O(1), а чтобы найти i-е слово, нам нужно обработать всю строку (что является операцией O(n)).
Другим выбором для типа индекса было бы использовать String.Index. Начальный индекс коллекции будет string.startIndex, индекс после этого будет индексом начала следующего слова и так далее. К сожалению, реализация сабскрипта будет иметь аналогичную проблему: нахождение конца слова также является O(n).
Вместо этого мы сделаем наш индекс оберткой вокруг Range<Substring.Index>. Поскольку индексы коллекции должны быть Comparable, нам также нужно реализовать этот протокол для нашего пользовательского типа индекса. Чтобы сравнить два индекса, мы сравниваем только нижние границы диапазона. Это не работает для сравнения Range в общем, но для нашей цели этого достаточно. И поскольку Comparable наследуется от Equatable, наш тип индекса также должен реализовать ==, но компилятор синтезирует это за нас.
Отметив свойство range и инициализатор как fileprivate, мы делаем WordsIndex непрозрачным типом; пользователи нашей коллекции не знают внутренней структуры, и единственный способ создать индекс — это через интерфейс коллекции:
struct WordsIndex: Comparable {
fileprivate let range: Range<Substring.Index>
fileprivate init(_ range: Range<Substring.Index>) {
self.range = range
}
static func <(lhs: WordsIndex, rhs: WordsIndex) -> Bool {
lhs.range.lowerBound < rhs.range.lowerBound
}
}
Теперь мы готовы построить нашу коллекцию Words. Она хранит исходную строку как Substring (мы увидим, почему в разделе о срезах) и предоставляет начальный индекс и конечный индекс. Протокол Collection требует, чтобы startIndex имел сложность O(1). К сожалению, его вычисление занимает O(n), где n — это количество пробелов в начале строки. Поэтому мы вычисляем его в инициализаторе и храним, вместо того чтобы определять его как вычисляемое свойство. Для конечного индекса мы используем пустой диапазон, который находится за пределами границ исходной строки:
struct Words {
let string: Substring
let startIndex: WordsIndex
init(_ s: String) {
self.init(s[...])
}
private init(_ s: Substring) {
self.string = s
self.startIndex = WordsIndex(string.nextWordRange)
}
public var endIndex: WordsIndex {
let e = string.endIndex
return WordsIndex(e..<e)
}
}
Протокол Collection также требует от нас предоставить сабскрипт, который получает доступ к элементам. Здесь мы можем напрямую использовать внутренний диапазон индекса. Обратите внимание, что использование диапазона слова в качестве индекса делает реализацию O(1):
extension Words {
public subscript(index: WordsIndex) -> Substring {
string[index.range]
}
}
В качестве последнего требования к Collection нам нужен способ вычислить индекс после данного индекса. Верхняя граница диапазона индекса указывает на позицию после текущего слова, так что если эта позиция не равна string.endIndex (что указывает на то, что мы достигли конца строки), мы можем взять подстроку от верхней границы и затем искать следующий диапазон слова:
extension Words: Collection {
public func index(after i: WordsIndex) -> WordsIndex {
guard i.range.upperBound < string.endIndex else { return endIndex }
let remainder = string[i.range.upperBound...]
return WordsIndex(remainder.nextWordRange)
}
}
Array(Words(" hello world test ").prefix(2)) // ["hello", "world"]
С некоторыми усилиями коллекцию Words можно изменить, чтобы решать более общие задачи. Прежде всего, мы могли бы сделать границу слова настраиваемой: вместо использования пробела мы могли бы передать функцию isWordBoundary: (Character) -> Bool. Во-вторых, код не является действительно специфичным для строк: мы могли бы заменить String на любой вид коллекции. Например, мы могли бы повторно использовать тот же алгоритм для ленивого разбиения Data на обрабатываемые куски. Снова, пакет Swift Algorithms уже делает все это с его ленивыми перегрузками split.
Подпоследовательности Link to heading
Протокол Collection имеет связанный тип SubSequence, который используется для возвращения непрерывных поддиапазонов коллекции:
extension Collection {
associatedtype SubSequence: Collection = Slice<Self>
where Element == SubSequence.Element,
SubSequence == SubSequence.SubSequence
}
SubSequence используется в качестве типа возвращаемого значения для операций, которые возвращают срезы оригинальной коллекции:
- prefix и suffix — берут первые или последние n элементов
- prefix(while:) — берёт элементы с начала, пока они соответствуют условию
- dropFirst и dropLast — возвращают подпоследовательности, из которых удалены первые или последние n элементов
- drop(while:) — удаляет элементы, пока условие не перестанет быть истинным, а затем возвращает оставшиеся
- split — разбивает последовательность по указанным разделяющим элементам и возвращает массив подпоследовательностей
Кроме того, оператор подстановки на основе диапазона принимает диапазон индексов и возвращает соответствующий срез. Преимущество возвращения подпоследовательности, а не, скажем, массива элементов, заключается в том, что не требуется новое выделение памяти, поскольку подпоследовательности используют хранилище своей базовой коллекции.
Термин “подпоследовательность” имеет историческую подоплеку. Изначально SubSequence был связанным типом протокола Sequence. Он был перемещён в Collection в Swift 5, потому что не решал многих проблем для Sequence и вызывал проблемы с производительностью. Имя было сохранено для поддержания обратной совместимости с существующим кодом, но в контексте коллекций термин “срез” часто более уместен и используется взаимозаменяемо с подпоследовательностью.
По умолчанию у Collection определён Slice как его SubSequence, но многие конкретные типы имеют свои собственные реализации: например, String имеет Substring в качестве своей подпоследовательности, а Array использует специальный тип ArraySlice.
Иногда может быть удобно, если SubSequence == Self — т.е. если подпоследовательности имеют тот же тип, что и базовая последовательность — потому что это позволяет передавать срез везде, где ожидается базовая коллекция. Тип Data в Foundation делает это, так что когда вы видите экземпляр Data, вы не можете знать, является ли он самостоятельным значением (с startIndex == 0 и endIndex == count) или срезом более крупного экземпляра Data (с ненулевыми индексами).
Однако коллекции стандартной библиотеки имеют отдельные типы срезов; основная мотивация для этого — избежать случайных “утечек” памяти, которые могут быть вызваны маленьким срезом, который удерживает свою базовую коллекцию (которая может быть очень большой) дольше, чем ожидалось. Создание срезов как собственных типов упрощает связывание их времени жизни с локальными областями видимости.
Как показывают примеры из стандартной библиотеки в приведённом выше списке, SubSequence и [SubSequence] являются хорошими типами возвращаемых значений для операций срезов. Следующий пример разбивает коллекцию на партии по n элементов. Это делается путём перебора индексов коллекции с шагом n, срезания кусочков и добавления этих кусочков в новый массив подпоследовательностей. Если количество элементов не является кратным n, последняя партия будет меньше:
extension Collection {
public func split(batchSize: Int) -> [SubSequence] {
var result: [SubSequence] = []
var batchStart = startIndex
while batchStart < endIndex {
let batchEnd = index(batchStart, offsetBy: batchSize, limitedBy: endIndex) ?? endIndex
let batch = self[batchStart ..< batchEnd]
result.append(batch)
batchStart = batchEnd
}
return result
}
}
let letters = "abcdefg"
let batches = letters.split(batchSize: 3) // ["abc", "def", "g"]
SwiftAlgorithms предоставляет эту операцию под именем chunks(ofCount:).
Поскольку подпоследовательность также является коллекцией (с тем же типом Element), мы можем бесшовно обрабатывать результат операции среза с теми же операциями коллекции, которые мы уже знаем. Например, давайте проверим, что количество элементов всех партий равно количеству элементов базовой коллекции:
let batchesCount = batches.map { $0.count }.reduce(0, +) // 7
batchesCount == letters.count // true
С их очень низкими накладными расходами на память, подпоследовательности отлично подходят для промежуточных значений. Но из-за опасности того, что очень маленький срез может случайно удерживать очень большую базовую коллекцию в живых, не рекомендуется хранить подпоследовательность постоянно (например, в свойстве) или передавать её другой функции, которая может это сделать. Чтобы разорвать связь между подпоследовательностью и её базовой коллекцией, мы можем создать совершенно новую коллекцию и передать подпоследовательность в инициализатор, например, String(substring) или Array(arraySlice).
Срезы Link to heading
Все коллекции получают стандартную реализацию операции среза и имеют перегрузку для подскрипта, которая принимает Range<Index>. Это эквивалентно words.dropFirst():
let words: Words = Words("one two three")
let onePastStart = words.index(after: words.startIndex)
let firstDropped = words[onePastStart..<words.endIndex]
Array(firstDropped) // ["two", "three"]
Поскольку операции, такие как words[somewhere..<words.endIndex] (срез от определенной точки до конца) и words[words.startIndex..<somewhere] (срез от начала до определенной точки), являются распространенными, в стандартной библиотеке есть варианты, которые выполняют эти операции более читаемым способом:
let firstDropped2 = words.suffix(from: onePastStart)
// или:
let firstDropped3 = words[onePastStart...]
По умолчанию тип firstDropped не будет Words — он будет Slice<Words>. Slice — это легковесная обертка над любой коллекцией. Реализация выглядит примерно так:
struct Slice<Base: Collection>: Collection {
typealias Index = Base.Index
let collection: Base
var startIndex: Index
var endIndex: Index
init(base: Base, bounds: Range<Index>) {
collection = base
startIndex = bounds.lowerBound
endIndex = bounds.upperBound
}
func index(after i: Index) -> Index {
return collection.index(after: i)
}
subscript(position: Index) -> Base.Element {
return collection[position]
}
subscript(bounds: Range<Index>) -> Slice<Base> {
return Slice(base: collection, bounds: bounds)
}
}
Slice — это вполне хорошая стандартная подтип последовательности, но каждый раз, когда вы создаете пользовательскую коллекцию, стоит рассмотреть возможность сделать коллекцию своим собственным SubSequence. Для Words это легко сделать:
extension Words {
subscript(range: Range<WordsIndex>) -> Words {
let start = range.lowerBound.range.lowerBound
let end = range.upperBound.range.upperBound
return Words(string[start..<end])
}
}
Компилятор выводит тип SubSequence из возвращаемого типа подскрипта на основе диапазона.
Использование одного и того же типа для коллекции и ее SubSequence упрощает жизнь пользователям коллекции, поскольку им нужно понимать только один тип вместо двух. С другой стороны, использование различных типов для “корневых” коллекций и их срезов делает ясным, когда срез удерживает память своей базовой коллекции, что и объясняет наличие в стандартной библиотеке ArraySlice и Substring.
ПодпоследовательностиРазделяютИндексыСОсновнойКоллекцией Link to heading
Формальным требованием протокола Collection является то, что индексы подпоследовательности могут использоваться взаимозаменяемо с индексами оригинальной коллекции. Документация утверждает:
Коллекция и ее срезы разделяют одни и те же индексы. Элемент коллекции находится под тем же индексом в срезе, как и в основной коллекции, при условии, что ни коллекция, ни срез не были изменены с момента создания среза.
Важным следствием этой модели является то, что, даже используя целочисленные индексы, индекс коллекции не обязательно будет начинаться с нуля. Вот пример начальных и конечных индексов среза массива:
let cities = ["New York", "Rio", "London", "Berlin", "Rome", "Beijing", "Tokyo", "Sydney"]
let slice = cities[2...4]
cities.startIndex // 0
cities.endIndex // 8
slice.startIndex // 2
slice.endIndex // 5
Случайный доступ к slice[0] в этой ситуации приведет к сбою вашей программы. Это еще одна причина всегда предпочитать конструкции типа for x in collection вместо ручной математики индексов.
Мы упомянули выше, что Data является особенно опасным примером этой функции. Поскольку Data использует целочисленные индексы, естественно предположить, что индексы всегда начинаются с 0, но это не может быть так, потому что Data является собственным типом SubSequence.
Если вам нужен доступ к индексам, for index in collection.indices предпочтительнее ручной математики индексов, если это возможно — с одним исключением: если вы изменяете коллекцию во время итерации по ее индексам, любая сильная ссылка, которую объект индексов удерживает к оригинальной коллекции, нарушит оптимизацию копирования при записи и может привести к созданию нежелательной копии. В зависимости от размера коллекции это может иметь значительное негативное влияние на производительность. (Не все коллекции используют тип Indices, который сильно ссылается на основную коллекцию, но многие используют, потому что именно это делает тип DefaultIndices стандартной библиотеки.)
Чтобы избежать этой нежелательной копии при итерации по индексам, вы можете заменить цикл for на цикл while и вручную продвигать индекс в каждой итерации, тем самым избегая свойства indices. Просто помните, что если вы делаете это, всегда начинайте цикл с collection.startIndex, а не с 0.
Специализированные коллекции Link to heading
Как и все хорошо спроектированные протоколы, Collection стремится сохранить свои требования как можно более минимальными. Чтобы позволить широкому спектру типов стать коллекциями, протокол не должен требовать от соответствующих типов предоставления большего, чем абсолютно необходимо для реализации желаемой функциональности.
Две особенно интересные ограничения заключаются в том, что Collection не может перемещать свои индексы назад и что он не предоставляет никакой функциональности — такой как вставка, удаление или замена элементов — для изменения коллекции. Это не значит, что соответствующие типы не могут иметь этих возможностей, конечно, но только то, что протокол не делает предположений о них.
Тем не менее, некоторые алгоритмы имеют дополнительные требования, и было бы неплохо иметь обобщенные варианты этих алгоритмов, даже если только некоторые коллекции могут их использовать. Для этой цели стандартная библиотека включает четыре специализированных протокола коллекций, каждый из которых уточняет Collection определенным образом, чтобы включить новую функциональность (цитаты взяты из документации стандартной библиотеки):
→ BidirectionalCollection — “Коллекция, которая поддерживает как обратный, так и прямой обход.”
→ RandomAccessCollection — “Коллекция, которая поддерживает эффективный обход индексов с произвольным доступом.”
→ MutableCollection — “Коллекция, которая поддерживает присваивание по индексу.”
→ RangeReplaceableCollection — “Коллекция, которая поддерживает замену произвольного поддиапазона элементов элементами другой коллекции.”
Давайте обсудим их по очереди.
BidirectionalCollection Link to heading
BidirectionalCollection добавляет одну, но критически важную возможность: возможность перемещения индекса назад с помощью метода index(before:). Это достаточно, чтобы дать вашей коллекции свойство last, которое соответствует:
extension BidirectionalCollection {
/// Последний элемент коллекции.
public var last: Element? {
return isEmpty ? nil : self[index(before: endIndex)]
}
}
Коллекция, безусловно, могла бы предоставить свойство last сама по себе, но это не было бы хорошей идеей. Чтобы получить последний элемент коллекции, которая поддерживает только прямую итерацию, вам нужно пройти до конца, т.е. выполнить операцию O(n). Было бы вводящим в заблуждение предоставлять маленькое свойство для последнего элемента — это занимает много времени для односвязного списка с миллионом элементов, чтобы получить последний элемент.
Пример двунаправленной коллекции в стандартной библиотеке — это String. По причинам, связанным с Unicode, о которых мы говорили в главе о строках, коллекция символов не может предоставить произвольный доступ к своим символам, но вы можете перемещаться назад от конца, символ за символом.
BidirectionalCollection также добавляет более эффективные реализации некоторых операций, которые выигрывают от обхода коллекции в обратном направлении, таких как suffix, removeLast и reversed. Последняя не сразу переворачивает коллекцию, а вместо этого возвращает ленивый вид:
extension BidirectionalCollection {
/// Возвращает вид, представляющий элементы коллекции в обратном
/// порядке.
/// - Сложность: O(1)
public func reversed() -> ReversedCollection<Self> {
return ReversedCollection(_base: self)
}
}
Так же, как и с оберточным типом enumerated для Sequence, фактического переворота не происходит. Вместо этого ReversedCollection хранит базовую коллекцию и использует пользовательский тип индекса, который проходит по базовой коллекции в обратном порядке. Коллекция затем меняет логику всех методов обхода индексов так, что движение вперед перемещает назад в базовой коллекции и наоборот.
Семантика значений играет большую роль в действительности этого подхода. При создании обертка “копирует” базовую коллекцию в свое собственное хранилище, так что последующая мутация оригинальной коллекции не изменит копию, хранящуюся в ReversedCollection. Это означает, что она имеет такое же наблюдаемое поведение, как версия reversed, которая возвращает массив.
Большинство типов в стандартной библиотеке, которые соответствуют Collection, также соответствуют BidirectionalCollection. Однако такие типы, как Dictionary и Set, не соответствуют — в основном потому, что идея прямой и обратной итерации имеет мало смысла для по своей природе неупорядоченных коллекций.
RandomAccessCollection Link to heading
RandomAccessCollection предоставляет самый эффективный доступ к элементам — он может перейти к любому индексу за постоянное время. Для этого типы, соответствующие этому протоколу, должны уметь (a) перемещать индекс на любое расстояние и (b) измерять расстояние между любыми двумя индексами, оба в O(1) времени. RandomAccessCollection переопределяет свои связанные типы Indices и SubSequence с более строгими ограничениями — оба должны быть случайно доступными сами по себе — но в остальном не добавляет новых требований к BidirectionalCollection. Тем не менее, разработчики должны позаботиться о том, чтобы соблюсти задокументированные требования к сложности O(1). Это можно сделать, предоставив реализации методов index(_:offsetBy:) и distance(from:to:), или используя тип Index, который соответствует Strideable, такой как Int.
Сначала может показаться, что RandomAccessCollection не добавляет много. Даже простая коллекция, которая только проходит вперед, такая как наша Words, может продвигать индекс на произвольное расстояние. Но есть большая разница. Для Collection и BidirectionalCollection метод index(_:offsetBy:) работает, последовательно увеличивая индекс, пока не достигнет назначения. Это явно занимает линейное время — чем больше расстояние, тем дольше будет выполняться операция. С другой стороны, случайно доступные коллекции могут просто перейти прямо к назначению.
Эта способность ключевая для ряда алгоритмов. Например, когда вы реализуете обобщенный бинарный поиск, критически важно, чтобы этот алгоритм был ограничен только случайно доступными коллекциями — в противном случае он будет значительно менее эффективным, чем просто поиск по коллекции от начала до конца.
Случайно доступная коллекция может вычислить расстояние между своим startIndex и endIndex за постоянное время, что означает, что коллекция также может вычислить count за постоянное время “из коробки”.
MutableCollection Link to heading
Изменяемая коллекция поддерживает мутацию элементов на месте. Единственное новое требование, которое она добавляет к коллекции, заключается в том, что у одноэлементного сабскрипта теперь также должен быть сеттер. Мы можем добавить соответствие для нашего типа очереди:
extension FIFOQueue: MutableCollection {
public var startIndex: Int { return 0 }
public var endIndex: Int { return left.count + right.count }
public func index(after i: Int) -> Int {
i + 1
}
**public subscript** (position: Int) -> Element {
get {
precondition((0..<endIndex).contains(position), "Индекс вне границ")
if position < left.endIndex {
return left[left.count - position - 1]
} else {
return right[position - left.count]
}
}
set {
precondition((0..<endIndex).contains(position), "Индекс вне границ")
if position < left.endIndex {
left[left.count - position - 1] = newValue
} else {
right[position - left.count] = newValue
}
}
}
}
Обратите внимание, что компилятор не позволит нам добавить сеттер сабскрипта в расширение существующей коллекции; не разрешается предоставлять сеттер без геттера, и мы не можем переопределить существующий геттер, поэтому нам нужно заменить существующее расширение, соответствующее коллекции. Теперь очередь изменяемая через сабскрипты:
var playlist: FIFOQueue = ["Shake It Off", "Blank Space", "Style"]
playlist.first // Optional("Shake It Off")
playlist[0] = "You Belong With Me"
playlist.first // Optional("You Belong With Me")
Соответствие MutableCollection является требованием для многих алгоритмов, которые работают с коллекцией на месте. Примеры в стандартной библиотеке включают сортировку на месте, реверсирование и метод swapAt.
Относительно немного типов в стандартной библиотеке принимают MutableCollection; из трех основных типов коллекций только Array это делает. MutableCollection позволяет изменять значения элементов коллекции, но не длину коллекции или порядок элементов. Эта последняя точка объясняет, почему Dictionary и Set не соответствуют MutableCollection, хотя они определенно являются изменяемыми структурами данных.
Словари и множества являются неупорядоченными коллекциями — порядок элементов не определен с точки зрения кода, использующего коллекцию. Однако, внутренне, даже эти коллекции имеют стабильный порядок элементов, который определяется их реализацией. Когда вы изменяете MutableCollection через присваивание сабскрипта, индекс измененного элемента должен оставаться стабильным, т.е. позиция индекса в коллекции индексов не должна изменяться. Словари и множества не могут удовлетворить это требование, потому что их индексы указывают на ведро в их внутреннем хранилище, где хранится соответствующий элемент, и это ведро может измениться, когда элемент будет изменен.
Строка также не является MutableCollection, потому что символы в строке не имеют фиксированного размера в буфере строки. В результате замена символа может занять линейное время, так как конец буфера может потребовать перемещения на несколько байтов назад или вперед, чтобы освободить место для заменяющего символа. Кроме того, изменение одного символа может сформировать новый графемный кластер с соседними комбинирующими символами, тем самым изменяя количество символов в строке в процессе.
RangeReplaceableCollection Link to heading
Для операций, которые требуют добавления или удаления элементов, используйте протокол RangeReplaceableCollection. Этот протокол требует двух вещей:
→ Пустой инициализатор — это полезно в обобщенных функциях, так как позволяет функции создавать новые пустые коллекции того же типа.
→ Метод replaceSubrange(_:with:) — этот метод принимает диапазон для замены и коллекцию, которой его нужно заменить.
RangeReplaceableCollection является отличным примером силы расширений протоколов. Вы реализуете один гибкий метод, replaceSubrange, и на его основе получаете множество производных методов бесплатно:
→ append(_:) и append(contentsOf:) — заменяют endIndex..<endIndex (т.е. заменяют пустой диапазон в конце) на новый элемент/элементы.
→ remove(at:) и removeSubrange(_:) — заменяют i...i или поддиапазон на пустую коллекцию.
→ insert(at:) и insert(contentsOf:at:) — заменяют i..<i (т.е. заменяют пустой диапазон в этой точке массива) на новый элемент/элементы.
→ removeAll — заменяет startIndex..<endIndex на пустую коллекцию.
Эти методы являются требованиями протокола, что означает, что когда конкретный тип коллекции может использовать знания о своей реализации для выполнения этих функций более эффективно, он может предоставить пользовательские версии, которые будут иметь приоритет над версиями по умолчанию из расширения протокола.
Мы выбрали простую неэффективную реализацию для нашего типа очереди. Как мы уже упоминали при определении типа данных, левый стек содержит элементы в обратном порядке. Чтобы иметь простую реализацию, нам нужно обратить все элементы и объединить их в правый массив, чтобы мы могли заменить весь диапазон сразу:
extension FIFOQueue: RangeReplaceableCollection {
mutating public func replaceSubrange<C: Collection>(
_ subrange: Range<Int>,
with newElements: C) where C.Element == Element {
right = left.reversed() + right
left.removeAll()
right.replaceSubrange(subrange, with: newElements)
}
}
Вам может быть интересно попробовать реализовать более эффективную версию, которая проверяет, охватывают ли заменяемые диапазоны разделение между левым и правым стеками. В этом примере нам не нужно реализовывать пустой инициализатор, так как структура FIFOQueue уже имеет его по умолчанию.
В отличие от BidirectionalCollection и RandomAccessCollection, где последняя расширяет первую, RangeReplaceableCollection не наследуется от MutableCollection; они образуют отдельные иерархии. Примером стандартной библиотеки коллекции, которая соответствует RangeReplaceableCollection, но не является MutableCollection, является String. Причины этого сводятся к тому, что мы говорили выше о необходимости оставлять индексы стабильными во время мутации по подписям с одним элементом, что String гарантировать не может. Мы подробнее обсудим это в главе о строках.
Составление возможностей Link to heading
Специализированные протоколы коллекций могут быть элегантно объединены в набор ограничений, которые точно соответствуют требованиям конкретного алгоритма. В качестве примера возьмем метод сортировки в стандартной библиотеке для сортировки коллекции на месте (в отличие от его немутирующего собрата, sorted, который возвращает отсортированные элементы в массиве). Сортировка на месте требует, чтобы коллекция была изменяемой. Если вы хотите, чтобы сортировка была быстрой, вам также нужен произвольный доступ. И, наконец, вам нужно иметь возможность сравнивать элементы коллекции друг с другом.
Объединив эти требования, метод sort определяется в расширении для MutableCollection с RandomAccessCollection и Element: Comparable в качестве дополнительных ограничений:
extension MutableCollection
where Self: RandomAccessCollection, Element: Comparable {
/// Сортирует коллекцию на месте.
public mutating func sort() { ... }
}
Ленивые последовательности Link to heading
Стандартная библиотека предоставляет два протокола для ленивой оценки: LazySequenceProtocol и LazyCollectionProtocol. Ленивая оценка означает, что результаты вычисляются только по мере необходимости, в отличие от жадной оценки, которая является значением по умолчанию в Swift. Ленивые последовательности создают разделение между производителем и потребителем последовательности: вы не создаете всю последовательность заранее. Вместо этого, как только потребитель запрашивает следующий элемент, ленивое последовательность производит его. Это может быть использовано как для достижения другого стиля программирования, так и по соображениям производительности.
Мы уже видели несколько ленивых последовательностей в этой главе. Мы показали три варианта построения последовательности Фибоначчи: с использованием пользовательского итератора, с использованием AnySequence и с использованием метода sequence(first:next:). Каждый из этих вариантов производит бесконечный, ленивый поток значений: следующее значение вычисляется только тогда, когда потребитель вызывает next. Это позволяет потребителю решать, сколько элементов ему нужно. Например, мы можем взять первый элемент этого потока или префикс.
Одна из проблем с лениво вычисляемыми последовательностями заключается в том, что некоторые преобразования требуют вычисления всей последовательности. Например, вспомним наше определение standardIn, однопроходной последовательности, которая считывает строки из стандартного ввода построчно. Мы можем захотеть напечатать только строки, которые соответствуют определенному условию. В функциональном стиле мы, вероятно, использовали бы filter, чтобы достичь этого. Однако, поскольку возвращаемый тип filter на Sequence — это массив (который должен быть вычислен жадно), реализация не имеет другой возможности, кроме как обработать весь стандартный ввод, прежде чем она сможет вернуть результат. Другими словами, результат больше не является ленивым. Следующий код напечатает все строки, которые содержат более трех слов, но только после того, как стандартный ввод отправит сигнал конца файла (EOF):
let filtered = standardIn.filter {
$0.split(separator: " ").count > 3
}
for line in filtered { print(line) }
В идеале, мы хотели бы, чтобы наша программа печатала соответствующие строки по мере их поступления. Одно из решений — переключиться на более императивный стиль:
for line in standardIn {
guard line.split(separator: " ").count > 3 else { continue }
print(line)
}
Если мы хотим использовать функциональный стиль с цепочкой методов вместо цикла for, нам нужно производить отфильтрованные элементы по мере их поступления, а не после того, как весь ввод был прочитан. Другими словами, нам нужно построить ленивую последовательность. Стандартная библиотека предоставляет свойство .lazy на Sequence, которое помогает нам достичь этого. Мы можем написать следующий код:
let filtered = standardIn.lazy.filter {
$0.split(separator: " ").count > 3
}
for line in filtered { print(line) }
Возвращаемый тип .lazy — это LazySequence<Self>. Это означает, что standardIn.lazy имеет тип LazySequence<AnySequence<String>>. LazySequence хранит внутреннюю последовательность, но не выполняет никакой дополнительной обработки. Метод filter на LazySequence затем возвращает LazyFilterSequence<AnySequence<String>>. В нашем примере кода выше это означает, что строки считываются из стандартного ввода по мере итерации по filtered. Вместо того чтобы ждать маркера EOF, строки печатаются немедленно. Более того, никакой промежуточный массив не создается.
Также интересно увидеть, как код меняется по мере изменения требований нашей программы. Например, давайте напечатаем только первую строку, которая содержит более трех слов. Используя lazy, мы можем вызвать .first, оставляя наше определение filtered без изменений:
let filtered = standardIn.lazy.filter {
$0.split(separator: " ").count > 3
}
print(filtered.first ?? "<none>")
Без lazy это прочитает все строки из стандартного ввода до получения сигнала EOF и только тогда напечатает первую подходящую строку. С lazy он читает только до первой совпадающей строки и оставляет остальной стандартный ввод необработанным. Чтобы изменить код для печати первых двух подходящих строк, мы можем изменить вызов first на prefix(2). Внесение изменений в эти функциональные конвейеры часто короче и яснее, чем изменение того же кода в императивном стиле. Например, вот тот же код, один раз с lazy, и один раз написанный императивно:
// Функциональный.
let result = Array(standardIn.lazy.filter {
$0.split(separator: " ").count > 3
}.prefix(2))
// Императивный.
var result: [String] = []
for line in standardIn {
guard line.split(separator: " ").count > 3 else { continue }
result.append(line)
if result.count == 2 { break }
}
Ленивая обработка коллекций Link to heading
Ленивая последовательность также может быть более эффективной при цепочном вызове нескольких методов на обычном типе коллекции, таком как массив. Если вы предпочитаете более функциональный стиль, вы можете быть склонны писать код вроде этого:
(1..<100).map { $0 * $0 }.filter { $0 > 10 }.map { "\($0)" }
Когда вы привыкли к функциональному программированию, приведенный выше код ясен и легко читаем. Однако он также неэффективен: каждый вызов map и filter создает промежуточный массив, который уничтожается на следующем шаге. Вставив .lazy в начало цепочки, промежуточные массивы не создаются, и код выполняется гораздо быстрее:
(1..<100).lazy.map { $0 * $0 }.filter { $0 > 10 }.map { "\($0)" }
Протокол LazyCollection расширяет LazySequence, требуя, чтобы соответствующий тип также был коллекцией. В ленивой последовательности мы можем производить только следующий элемент лениво, но в коллекции мы также можем вычислять отдельные элементы лениво. Например, когда вы применяете map к ленивой коллекции, а затем получаете элемент с помощью подскрипта, преобразование map применяется только к этому одному элементу (в данном случае, по индексу 50):
let allNumbers = 1..<1_000_000
let allSquares = allNumbers.lazy.map { $0 * $0 }
print(allSquares[50]) // 2500
Использование ленивых коллекций не всегда приводит к повышению производительности; есть несколько моментов, которые следует учитывать. При доступе к элементам через подскрипт значение вычисляется каждый раз, когда вы к нему обращаетесь. Точно так же, в зависимости от вычислений, которые коллекция должна выполнить, доступ через подскрипт может больше не быть O(1). Например, рассмотрим следующий код:
let largeSquares = allNumbers.lazy.filter { $0 > 1000 }.map { $0 * $0 }
print(largeSquares[50]) // 2500
Перед выполнением оператора print ни filter, ни map не выполнили никакой реальной работы. Из-за операции filter индекс 50 в largeSquares не соответствует индексу 50 в allNumbers. Чтобы найти правильный элемент, filter необходимо вычислить первые 51 элемент при каждом доступе через подскрипт, и это явно не операция постоянного времени.
Резюме Link to heading
Протоколы Sequence и Collection составляют основу коллекций Swift. Они предоставляют десятки общих операций для типов, соответствующих этим протоколам, и служат ограничениями для ваших собственных обобщенных функций. Специализированные типы коллекций, такие как MutableCollection или RandomAccessCollection, дают вам детальный контроль над функциональностью и требованиями к производительности ваших алгоритмов.
Высокий уровень абстракции неизбежно делает модель сложной, поэтому не стоит расстраиваться, если не все сразу становится понятным. Требуется практика, чтобы привыкнуть к строгой типизации, особенно поскольку, чаще всего, понимание того, что компилятор пытается вам сказать, является искусством, которое заставляет вас внимательно читать между строк. Наградой за это является чрезвычайно гибкая система, способная обрабатывать все, от указателя на буфер памяти до потока сетевых пакетов, который потребляется разрушительно.
Эта гибкость означает, что как только вы усвоите модель, есть большая вероятность, что много кода, с которым вы столкнетесь в будущем, будет мгновенно казаться знакомым, потому что он построен на тех же абстракциях и поддерживает те же операции. И всякий раз, когда вы создаете пользовательский тип, который вписывается в структуру Sequence или Collection, подумайте о том, чтобы добавить соответствие. Это упростит жизнь как вам, так и другим разработчикам, работающим с вашим кодом.
Параллелизм Link to heading
12 Link to heading
В Swift 5.5 в язык были добавлены основные функции параллелизма. Это позволяет нам писать конкурентный код с сильной поддержкой компилятора, что делает определенный класс ошибок невозможным. Async/await — это наиболее заметное изменение, которое позволяет нам использовать структурированные методы программирования Swift, такие как встроенная обработка ошибок, для асинхронного кода так, как если бы мы писали синхронный код. По сравнению с обработчиками завершения, async/await проще и легче для понимания.
В этой главе мы загрузим эпизоды SwiftTalk, коллекции и связанные данные из сети в качестве примера. Мы заранее определили структуры Episode и Collection, которые обе соответствуют Codable и Identifiable. Например, вот тип Episode:
struct Episode: Identifiable, Codable {
var id: String
var poster_url: URL
var collection: String
// ...
static let url = URL(string: "https://talk.objc.io/episodes.json")!
}
Загрузка эпизодов из сети и их парсинг как JSON выглядит так с использованием async/await:
func loadEpisodes() async throws -> [Episode] {
let session = URLSession.shared
let (data, _) = try await session.data(from: Episode.url)
return try JSONDecoder().decode([Episode].self, from: data)
}
Вот тот же пример без async/await, с использованием обработчика завершения вместо этого:
func loadEpisodesCont(
_ completion: @escaping (Result<[Episode], Error>) -> ()
) {
let session = URLSession.shared
let task = session.dataTask(with: Episode.url) { data, _, err in
completion(Result {
guard let d = data else {
throw (err ?? UnknownError())
}
return try JSONDecoder().decode([Episode].self, from: d)
})
}
task.resume()
}
Обратите внимание, что в первом примере (с использованием async/await) выполнение происходит сверху вниз, как в нормальном структурированном программировании. Пока данные загружаются из сети, функция приостанавливается. Тем временем, во втором примере (с использованием обработчиков завершения) выполнение ветвится: одна часть продолжает выполняться после возобновления задачи данных, в то время как другая часть (тело обработчика завершения) выполняется асинхронно, когда сетевой запрос завершен (или завершился неудачей). Смотрим на типы наших функций, мы можем видеть, что первая функция возвращает массив или выбрасывает ошибку. С обработчиком завершения это только по соглашению, что мы предполагаем, что обработчик завершения будет вызван ровно один раз.
Обработчик завершения также может называться продолжением. Это название описывает цель обработчика как точку, где выполнение продолжается, когда loadEpisodesCont завершил свою работу. Аналогично, метод session.dataTask не возвращает данные; вместо этого он вызывает предоставленное продолжение (в данном случае, выражение замыкания). Async/await также использует продолжения “под капотом”. В приведенном выше примере часть после await является продолжением.
Система параллелизма в Swift только в начале своего пути. В будущем будут добавлены новые функции, а существующие функции будут уточнены и улучшены. Кроме того, некоторые проверки времени компиляции еще не полностью реализованы. Чтобы убедиться, что ваш код готов к будущему, добавьте следующие флаги компилятора Swift: -Xfrontend-warn-concurrency и -Xfrontend-enable-actor-data-race-checks. Они дадут вам полезные предупреждения, когда вы используете небезопасные конструкции.
Async/Await Link to heading
Перед использованием async/await мы писали наш неблокирующий код, используя обработчики завершения (continuations) и делегаты. Это было стандартной практикой на протяжении многих лет в экосистеме Apple. Однако этот шаблон может привести к глубоко вложенным продолжениям, которые трудно отслеживать. Еще хуже, стандартные конструкции структурированного программирования — такие как встроенная обработка ошибок Swift и defer — не могут быть использованы.
Когда мы сравниваем два приведенных выше примера кода, несколько вещей выделяются. Во-первых, код с использованием async/await короче (три строки против десяти строк для подхода на основе обработчиков завершения). Во-вторых, обработчики завершения труднее читать; порядок выполнения не идет сверху вниз, так как продолжение выполняется после того, как функция вернула результат. Наконец, при написании кода с обработчиками завершения легко допустить ошибки — например, легко забыть вызвать обработчик завершения в случае ошибки. Смотря на тип функции, неясно, как часто вызывается обработчик завершения; вам нужно обратиться к документации и надеяться, что она актуальна.
По сути, версия с async/await короче и легче для понимания. Она также более точная; глядя на тип, мы знаем, что она вернет либо ошибку, либо массив эпизодов, и что она вернет результат ровно один раз.
Комбинировать код с async/await гораздо проще, чем комбинировать код с обработчиками завершения, особенно учитывая обработку ошибок. Например, давайте расширим наш предыдущий пример, чтобы загрузить изображение постера первого эпизода. Единственное, что нам нужно сделать, это добавить еще одну строку в нашу функцию:
func loadFirstPoster() async throws -> Data {
let session = URLSession.shared
let (data, _) = try await session.data(from: Episode.url)
let episodes = try JSONDecoder().decode([Episode].self, from: data)
let (imageData, _) = try await session.data(from: episodes[0].poster_url)
return imageData
}
В сравнении, гораздо сложнее добавить загрузку первого изображения постера в код, используя обработчики завершения. Теперь нам нужно быть очень внимательными, чтобы убедиться, что наша обработка ошибок корректна. Если мы не можем загрузить начальные данные или если у нас есть ошибка парсинга, нам нужно убедиться, что мы немедленно вызываем наш обработчик завершения с ошибкой. В противном случае мы можем продолжить загрузку изображения постера и вызвать наш обработчик завершения, как только это будет сделано.
Реализация с использованием обработчиков завершения гораздо более сложна: не очевидно, что она корректна (вызываем ли мы обработчик завершения ровно один раз для всех различных сценариев?), и управление потоком гораздо труднее отслеживать. Если мы допустим ошибку (например, не вызовем обработчик завершения), мы не можем полагаться на компилятор, чтобы он предупредил нас об этом:
func loadFirstPosterCont(_ completion: @escaping (Result<Data, Error>) -> ()) {
let session = URLSession.shared
let task = session.dataTask(with: Episode.url) { data, _, err in
do {
guard let d = data else {
throw (err ?? UnknownError())
}
let episodes = try JSONDecoder().decode([Episode].self, from: d)
let inner = session.dataTask(with: episodes[0].poster_url) { data, _, err in
completion(Result {
guard let d = data else {
throw (err ?? UnknownError())
}
return d
})
}
inner.resume()
} catch {
completion(.failure(error))
}
}
task.resume()
}
Теперь мы увидели, что как реализация, так и интерфейс кода с использованием async/await гораздо проще, чем код, использующий обработчики завершения, и компилятор может устранить целый класс ошибок (хотя не все). Еще одно преимущество async/await перед обработчиками завершения заключается в том, что мы можем использовать defer для выполнения любых действий, которые должны быть выполнены перед выходом из области видимости. Однако обратите внимание, что тело оператора defer не позволяет выполнять асинхронный код. Например, вы не можете асинхронно обновить объект модели внутри оператора defer.
Как выполняются асинхронные функции Link to heading
Чтобы понять выполнение асинхронной функции (в дальнейшем будем называть её «асинхронной функцией»), мы разделим её на отдельные части, где каждая часть ограничена потенциальной точкой приостановки. Другими словами, мы разделим функцию на части на каждом операторе await. Рассмотрим функцию из предыдущего примера, теперь аннотированную частями:
func loadFirstPoster() async throws -> Data {
// Часть 1
let session = URLSession.shared
let (data, _) = try await session.data(from: Episode.url)
// Часть 2
let episodes = try JSONDecoder().decode([Episode].self, from: data)
let imageData = try await session.data(from: episodes[0].poster_url).0
// Часть 3
return imageData
}
Если мы перепишем нашу функцию, чтобы использовать обработчики завершения, части будут соответствовать каждому синхронному блоку внутри функции. Внутри Swift переписывает каждую асинхронную функцию, содержащую точки приостановки, в продолжения. Первая часть функции выше выполняется нормально, но части две и три будут продолжениями.
При выполнении кода выше «Часть 1» выполняется синхронно. Этот блок работы называется задачей. Функция затем приостанавливается, ожидая загрузки данных из сети. Эта загрузка выполняется в отдельной задаче. Как только данные загружены, loadFirstPoster возобновляется, и новая задача запускается, которая синхронно выполняет код, помеченный как «Часть 2». Чтобы загрузить данные изображения, функция снова приостанавливается, и запланирована другая задача. Как только эта задача завершится, наша функция может возобновиться и, наконец, вернуть своё значение.
Модель параллелизма Swift называется кооперативным многозадачностью. Короче говоря, это означает, что функции никогда не должны блокировать текущий поток, а вместо этого должны добровольно приостанавливать себя. Функция может быть приостановлена только в потенциальной точке приостановки (отмеченной await). Когда функция приостанавливается, это не означает, что текущий поток заблокирован: вместо этого управление передаётся планировщику, и другие задачи (соответствующие другим задачам) могут выполняться в потоке в это время. В более поздний момент времени планировщик возобновляет функцию, вызывая её продолжение. Например, в функции выше функция приостанавливается перед «Частью 2», пока данные загружаются из сети. Другие задачи могут выполняться между этим, и как только данные становятся доступными, функция возобновляется.
Обратите внимание, что приостановленная функция не гарантирует возобновление на своём оригинальном потоке. Среда выполнения Swift поддерживает пул потоков для выполнения асинхронных задач и выполняет задачи на этих потоках по мере их доступности. Конкретный поток, на котором выполняется асинхронная функция, не должен (и обычно не является) важным — если только мы не говорим о главном потоке, который всё ещё получает особое обращение.
Потенциальный переход между потоками задач означает, что мы не можем полагаться на то, что локальные значения потока останутся прежними после точки приостановки. Поэтому API, которые используют локальные значения потока, такие как класс Progress в Foundation, несовместимы с асинхронными функциями. Замена для локального хранения потока, осведомлённая о параллелизме, называется локальным хранилищем задач. Мы не будем обсуждать это в этой главе, но вы можете прочитать об этом в соответствующем предложении Swift Evolution.
Однако текущий поток не единственное значение, которое может измениться в точке приостановки. Более общим является то, что вы должны предполагать при возобновлении, что все не локальные состояния могли измениться, потому что другой код имел возможность выполняться, пока функция была приостановлена (не локальное состояние включает глобальные переменные и свойства на self, если только self не имеет семантики значения). Это может вызвать ошибки, которые трудно найти и которые компилятор не может поймать. Мы вернёмся к этой точке в разделе о повторном входе акторов.
Модель параллелизма называется кооперативной, потому что она полагается на то, что отдельные функции работают вместе: ни одна функция никогда не должна блокировать свой поток, ожидая дорогостоящие операции ввода-вывода или выполняя длительную работу. Вместо этого функции должны приостанавливать себя, используя другие асинхронные функции для ввода-вывода или вызывая Task.yield() между длительной работой, чтобы дать другим задачам возможность выполняться.
Async/await лучше всего подходит для ожидания, связанного с вводом-выводом, потому что система параллелизма Swift разработана для эффективного переключения задач. Приостановка одной функции и возобновление другой на освобождённом потоке происходит гораздо быстрее, чем переключение на другой поток или создание нового потока. Если функции могут ожидать медленный ввод-вывод, не занимая поток, система может поддерживать ядра ЦПУ занятыми с минимальными накладными расходами.
Система параллелизма также поддерживает отмену, которая также является кооперативной. Мы поговорим больше об отмене позже в этой главе.
Вы могли заметить, что вызов JSONDecoder().decode в нашем примере не является асинхронным, потому что этот API в настоящее время не имеет асинхронного варианта. Если мы ожидаем обрабатывать большие объёмы данных, шаг декодирования может занять несколько сотен миллисекунд или даже дольше, и в это время наша функция не является хорошим гражданином параллелизма, потому что она не даёт другим задачам возможности выполняться.
Сказав это, краткое занятие потока не является концом света, особенно если ЦПУ делает что-то полезное (как это происходит во время декодирования JSON). Если только все выполняющиеся функции не начнут «плохо себя вести» одновременно, планировщик всё равно может распределять задачи на другие ядра ЦПУ — кооперативный пул потоков разработан так, чтобы использовать примерно столько же потоков, сколько у машины ядер ЦПУ.
Тем не менее, имейте в виду, что планировщик не будет создавать новые потоки, чтобы поддерживать отзывчивость программы, так как это может привести к «взрыву потоков», что стало источником проблем с производительностью в Grand Central Dispatch (GCD). Обратная сторона заключается в том, что несколько некоперативных функций могут остановить весь пул потоков, поэтому мы действительно должны избегать блокировки, если это возможно.
В теории каждая часть функции между двумя точками приостановки будет синхронно выполняться как отдельная задача. Каждый вызов асинхронной функции также создаёт отдельную задачу, которая, возможно, выполняется на другом акторе. Однако иногда несколько задач могут быть «слияны» вместе и эффективно выполняться как одна большая непрерывная задача.
Например, в Foundation есть свойство bytes у FileHandle для асинхронного чтения байтов из файла. Это реализовано с использованием типа AsyncBytes. AsyncBytes является AsyncSequence (асинхронный вариант Sequence), с методом next, который возвращает один байт. Метод next примерно реализован так:
mutating func next() async {
if !buffer.isEmpty {
return buffer.removeFirst()
} else {
return await reloadBufferAndNext()
}
}
Благодаря слиянию задач, вызов метода next будет фактически как обычный синхронный вызов, если только буфер не пуст, в этом случае метод приостанавливается и возвращает только после чтения дополнительных данных. Хотя нам придётся написать await при вызове next, это действительно является потенциальной точкой приостановки: в большинстве случаев буфер не будет пустым, и текущая задача не будет приостановлена.
Интерфейсирование с обработчиками завершения Link to heading
В существующих проектах у вас может быть код, который использует обработчики завершения. Это может быть ваш собственный код, код сторонних разработчиков или код Apple, который еще не был обновлен до async/await. Используя withCheckedContinuation (или один из трех тесно связанных вариантов), вы можете обернуть любую функцию, которая принимает обработчик завершения, в асинхронную функцию.
Например, давайте представим, что у нас есть функция для загрузки одного эпизода с обработчиком завершения:
func loadEpisodeCont(id: Episode.ID, _ completion: (Result<Episode, Error>) -> ()) {
// ...
}
Чтобы превратить это в асинхронный API, мы пишем новую асинхронную функцию и оборачиваем вызов оригинальной функции в вызов withCheckedThrowingContinuation. Последняя выполнит свое тело немедленно и затем приостановит выполнение до тех пор, пока мы не вызовем cont.resume внутри тела:
func loadEpisode(id: Episode.ID) async throws -> Episode {
try await withCheckedThrowingContinuation { cont in
loadEpisodeCont(id: id) {
cont.resume(with: $0)
}
}
}
Поскольку обработчик завершения в нашем оригинальном методе loadEpisodeCont может быть вызван с ошибочным значением (т.е. Result является случаем .failure), нам нужно пометить наш асинхронный обертку как throws, и нам нужно использовать withCheckedThrowingContinuation, а не withCheckedContinuation. Включение “Checked” означает, что обе эти функции выполняют проверки во время выполнения, чтобы гарантировать, что мы вызываем resume ровно один раз. Вызов его более одного раза является ошибкой времени выполнения. Если мы отбрасываем продолжение до его вызова, мы также получаем предупреждение во время выполнения.
withUnsafeContinuation и withUnsafeThrowingContinuation — это два дополнительных варианта, и они немного более эффективны во время выполнения, поскольку пропускают проверки безопасности своих проверенных аналогов: вам нужно убедиться, что вы вызываете продолжение ровно один раз. Невызов продолжения приводит к тому, что задача никогда не возобновляется, а вызов его более одного раза является неопределенным поведением. Хорошей практикой является написание вашего кода с использованием проверенных вариантов и обеспечение их работы перед переключением на небезопасные варианты (если вам действительно нужна производительность).
Функции with[...]Continuation полезны не только для интерфейсирования с существующим кодом на основе обработчиков завершения. По сути, они позволяют вам вручную приостановить задачу и возобновить ее позже. На нашем опыте, вам нужно быть очень осторожным, чтобы правильно это сделать в любых, кроме самых простых сценариев. Например, тип AsyncStream из стандартной библиотеки использует withUnsafeContinuation, чтобы приостановить потребителя, ожидая, пока будут произведены дополнительные элементы.
Кстати, вместо того чтобы писать loadEpisode как асинхронную функцию, мы также могли бы написать его как асинхронный инициализатор:
extension Episode {
init(id: Episode.ID) async throws {
self = try await withCheckedThrowingContinuation { cont in
loadEpisodeCont(id: id, cont.resume(with:))
}
}
}
Когда вы хотите получить доступ к вашим асинхронным методам из Objective-C, существуют некоторые ограничения. Вы можете пометить асинхронные методы как @objc, чтобы сделать их видимыми для Objective-C как методы с обработчиком завершения, но вы не можете пометить вариант init как @objc, потому что асинхронные инициализаторы не мостятся. Существует общее ограничение, что глобальные функции Swift не могут быть помечены как @objc, поэтому они должны быть представлены как методы на классе, который уже виден для Objective-C.
Структурированная конкуренция Link to heading
Мы увидели, как async/await делает асинхронный код структурированным, позволяя нам использовать встроенные конструкции управления потоком Swift: условные операторы, циклы, обработку ошибок и операторы defer, которые работают так же, как и в синхронном коде. Однако async/await сам по себе не вводит конкуренцию, т.е. выполнение нескольких задач одновременно. Для этого нам нужен способ создания новых задач.
Задачи Link to heading
Задача является основным контекстом выполнения в модели параллелизма Swift. Каждая асинхронная функция выполняется в задаче (также как и синхронные функции, вызываемые асинхронными функциями). Задачи выполняют примерно ту же роль, что и потоки в традиционном многопоточном коде. Как и поток, задача сама по себе не имеет параллелизма; она выполняет одну функцию за раз. Когда выполняемая задача сталкивается с await, она может приостановить свое выполнение, уступив свой поток и передав управление планировщику в среде выполнения Swift. Планировщик затем может запустить другую задачу на том же потоке. Когда приходит время возобновить первую задачу, она продолжит выполнение точно с того места, где остановилась (возможно, на другом потоке).
Дочерние задачи против Неструктурированных задач Link to heading
Когда мы вызываем асинхронную функцию с помощью await, вызываемая функция будет выполняться в том же контексте задачи, что и вызывающая. Создание новой задачи всегда требует явного действия. Мы можем создать два типа задач:
→ Дочерние задачи — Они формируют основу структурированной конкурентности. Мы создаем дочернюю задачу с помощью одного из конструкций структурированной конкурентности: async let или групп задач. Дочерние задачи организованы в дерево и имеют ограниченный срок жизни. Мы подробно обсудим дочерние задачи ниже.
→ Неструктурированные задачи — Это отдельные задачи, которые становятся корнем нового независимого дерева задач. Мы создаем неструктурированную задачу, вызывая либо инициализатор Task, либо фабричный метод Task.detached. Срок жизни неструктурированной задачи независим от срока жизни текущей задачи. Мы рассмотрим неструктурированные задачи в разделе о Неструктурированной конкурентности.
TheTaskTree Link to heading
Основная идея структурированной конкурентности заключается в том, чтобы расширить концепции структурного программирования (ясный поток управления и ограниченные по времени жизни) на несколько задач, выполняющихся одновременно. Это достигается путем организации задач в древовидную структуру и наложения правил на время жизни этих задач:
→ Дочерние задачи выполняются одновременно друг с другом — Текущая задача может создать одну или несколько дочерних задач, которые выполняются параллельно, и текущая задача становится родительской для дочерних задач. Родительская задача также выполняется одновременно с дочерними задачами.
→ Дочерние задачи не могут пережить свою родительскую задачу — Время жизни каждой дочерней задачи ограничено областью, в которой она была создана, так же как время жизни локальной переменной заканчивается в конце ее области видимости. Родительская задача не может выйти из области видимости, не дождавшись завершения всех дочерних задач, так же как асинхронная функция не может вернуть результат, пока все функции, которые она вызвала, не завершатся.
→ Отмена распространяется вниз от родителя к детям — Это гарантирует, что единая отмена отменяет все поддерево задач ниже, независимо от того, насколько глубоко расположено дерево.
В результате асинхронная функция может временно разветвляться на несколько одновременно выполняющихся подзадач, ограничивая конкурентность текущей областью видимости. В свою очередь, автор функции может быть уверен, что никакие висячие ресурсы (выполняющиеся задачи) не остаются активными после возврата функции.
Дочерние задачи также унаследуют приоритет своей родительской задачи (если не переопределено явно) и локальные значения задач. Поскольку зависимости между задачами в дереве задач известны, планировщик может приоритизировать дочерние задачи (и их дочерние задачи, если таковые имеются), если родительская задача с более высоким приоритетом ожидает их завершения.
asynclet Link to heading
Синтаксис asynclet — это самый быстрый способ создать дочернюю задачу. Продолжая с примера из раздела Async/Await, следующая функция загружает эпизоды и коллекции эпизодов из сети параллельно:
// loadCollections имеет такую же структуру, как loadEpisodes выше.
func loadCollections() async throws -> [Collection] { ... }
func loadEpisodesAndCollections() async throws -> ([Episode], [Collection]) {
async let episodes = loadEpisodes()
async let collections = loadCollections()
return try await (episodes, collections)
}
Синтаксис async let episodes = loadEpisodes() создает асинхронное связывание. Когда выполнение достигает этой строки, среда выполнения создает новую дочернюю задачу и выполняет вызов loadEpisodes() в этой задаче. Дочерняя задача начинает выполняться немедленно. Тем временем родительская задача продолжает выполняться — обратите внимание, что нам не нужно было писать await, даже несмотря на то, что loadEpisodes является асинхронной функцией. В следующей строке запускается вторая дочерняя задача для выполнения loadCollections().
Затем родительская задача ожидает асинхронные связывания, чтобы собрать результаты дочерних задач. Это выражение try await (episodes, collections) в нашем примере, и именно здесь компилятор требует от нас написать await (и try, если применимо), чтобы подтвердить, что родительская задача может приостановиться, пока дочерние задачи не завершатся. В общем, родительская задача может выполнять другую (синхронную или асинхронную) работу перед ожиданием дочерних задач, потому что родительские и дочерние задачи выполняются параллельно. В нашем примере родительской задаче нечего больше делать, поэтому она немедленно ожидает дочерние задачи.
Давайте рассмотрим самые важные правила для асинхронных связываний:
- Доступ к значению
async letво второй раз не приостановит выполнение. Как только родительская задача ожидает асинхронное связывание, его значение немедленно становится доступным для последующих обращений. Чтение его снова не перезапустит дочернюю задачу и не приостановит родительскую задачу (хотя вам нужно будет написатьawaitв обоих местах). Например, следующий код приостанавливается только один раз и выводит одно и то же число дважды:
// IO-емкий генератор случайных чисел, поэтому асинхронный.
func requestRandomNumber() async -> Int { ... }
async let rand = requestRandomNumber()
await print(rand) // приостанавливает
await print(rand) // не приостанавливает, не перезапускает request
Асинхронные связывания не имеют отдельной идентичности в системе типов. То есть, в отличие от многих других языков, Swift не моделирует связывания async let как futures или promises — тип async let episodes это [Episode], а не Future<[Episode]> или что-то вроде episodes: async [Episode]. Это преднамеренное ограничение: если бы дочерние задачи возвращали значения Future, программисты могли бы передавать эти futures, потенциально позволяя им выйти за пределы текущей области видимости, что нарушило бы гарантии жизненного цикла, предоставляемые структурированной конкуренцией.
Неожиданные async let будут неявно отменены и ожидаемы в конце области видимости. Требование ожидать неожиданные дочерние задачи вытекает из гарантии структурированной конкуренции, что дочерние задачи не могут пережить своих родителей. Любая дочерняя задача, которая не завершилась, должна быть разрешена до того, как родительская задача сможет выйти из области видимости, в которой она создала дочернюю задачу. Вот пример программы, которая переходит на одну из двух страниц в зависимости от ввода пользователя. Чтобы минимизировать время ожидания, мы запускаем две дочерние задачи для предварительной загрузки содержимого обеих страниц, ожидая действия пользователя (в третьей дочерней задаче). Когда поступает ввод пользователя, мы выбираем ожидать только одну из двух дочерних задач:
func waitForUserInput() async -> NavigationAction { ... }
func loadPage(at pageIndex: Int) async -> Page { ... }
func navigate(currentPage: Page) async -> Page {
async let nextAction = waitForUserInput()
async let nextPage = loadPage(at: currentPage.index + 1)
async let previousPage = loadPage(at: currentPage.index - 1)
switch await nextAction {
case .nextPage:
return await nextPage
// Неявно отменяет и ожидает previousPage
case .previousPage:
return await previousPage
// Неявно отменяет и ожидает nextPage
}
}
В каждом ветвлении оператора switch неожиданная дочерняя задача будет неявно отменена и ожидаема перед возвратом функции. Обратите внимание, что отмена не обязательно означает, что дочерняя задача завершится быстро, потому что отмена, как и приостановка, является кооперативной в модели конкуренции Swift. Мы еще скажем больше о отмене позже в этой главе. Кстати, группы задач (которые мы рассмотрим в следующем разделе) демонстрируют немного отличающееся поведение отмены при выходе из своих областей: группа задач не будет неявно отменять неожиданные дочерние задачи при нормальном (т.е. не выбрасывающем) выходе.
Асинхронное связывание создает единую задачу, даже если оно содержит несколько асинхронных вызовов. Все выражение справа от знака равенства обернуто в задачу. Например, следующий код создает единую дочернюю задачу, которая затем последовательно выполняет два асинхронных вызова функций:
// Одна дочерняя задача
async let (num1, num2) = (requestRandomNumber(), requestRandomNumber())
await print(num1) // Ожидает num1 и num2.
await print(num2) // num2 уже доступен.
Система всегда ожидает дочернюю задачу как единое целое, даже если наш код ожидает только часть дочерней задачи. То есть, await num1 в приведенном выше коде приостановит выполнение до тех пор, пока вся дочерняя задача не завершится, даже если результат num1 доступен раньше. Из-за этой семантики await num1 также делает num2 доступным — последующий await num2 поэтому не потребуется приостанавливать выполнение снова.
В отличие от предыдущего фрагмента, следующий код выполняет два вызова функций параллельно, поскольку они находятся в отдельных дочерних задачах:
// Две параллельные дочерние задачи
async let num3 = requestRandomNumber()
async let num4 = requestRandomNumber()
await print(num3) // Ожидает только num3.
await print(num4) // Ожидает только num4.
async let — это удобный, легковесный синтаксис, когда количество дочерних задач известно на этапе компиляции. Для динамического количества дочерних задач нам нужен другой инструмент: группы задач.
TaskGroups Link to heading
Группа задач (task group) предоставляет динамическое количество параллелизма, т.е. количество дочерних задач определяется во время выполнения. В качестве примера, эта функция загружает изображения постеров для массива эпизодов параллельно:
func loadPosterImages(for episodes: [Episode]) async throws -> [Episode.ID: Data] {
let session = URLSession.shared
return try await withThrowingTaskGroup(of: (id: Episode.ID, image: Data).self) { group in
for episode in episodes {
group.addTask {
let (imageData, _) = try await session.data(from: episode.poster_url)
return (episode.id, imageData)
}
}
return try await group.reduce(into: [:]) { dict, pair in
dict[pair.id] = pair.image
}
}
}
Мы создаем группу задач с одной из двух функций — withTaskGroup(of:returning:body:) или withThrowingTaskGroup — в зависимости от поведения дочерних задач (система типов Swift не может выразить эти два случая как одну функцию). Функция withTaskGroup принимает как тип результата для дочерних задач, так и замыкание, которое предоставляет нам экземпляр TaskGroup. Обратите внимание, что вызов withTaskGroup еще не создает дочернюю задачу — замыкание выполняется в родительской задаче.
Затем мы вызываем TaskGroup.addTask { ... } (или addTaskUnlessCancelled) для каждой дочерней задачи, которую мы хотим создать, обычно в цикле. Дочерние задачи начинают выполняться немедленно и в любом порядке. Замыкание, которое мы передаем в addTask, возвращает значение, которое становится результатом этой дочерней задачи, но обратите внимание, что мы не получаем эти результаты сразу. Вместо этого группа задач собирает результаты всех дочерних задач и предоставляет их нам как AsyncSequence. В нашем примере мы вызываем reduce на группе задач, чтобы сохранить результаты в словаре, но мы могли бы использовать асинхронный цикл for так же:
return try await withThrowingTaskGroup(of: (id: Episode.ID, image: Data).self) { group in
// ...
var result: [Episode.ID: Data] = [:]
for try await (id, imageData) in group {
result[id] = imageData
}
return result
}
Эта структура замыкания группы задач — один цикл для запуска дочерних задач, за которым следует другой цикл для сбора и обработки их результатов — является типичным шаблоном. Она напоминает популярный алгоритм MapReduce, где родительский узел делит свою работу на части и распределяет эти части независимым работникам. По мере того как работники доставляют свои результаты, родитель объединяет их в агрегированный результат. Мы можем распознать парадигму структурированного параллелизма, вводя параллелизм выборочно и ограничивая его эффекты.
Подумайте, насколько сложнее было бы реализовать loadPosterImages, используя обработчики завершения. Мы не показываем код здесь, но мы разместили нашу попытку на GitHub. Два основных аспекта этой версии сложно реализовать правильно. Во-первых, нам нужен способ собрать результаты параллельно выполняющихся задач URLSession. Когда эти “дочерние задачи” завершаются и вызывают свои соответствующие обработчики завершения, мы добавляем их результаты в общий локальный массив. Мы должны защитить этот массив с помощью последовательной очереди (или блокировки), чтобы предотвратить гонки данных, когда два обработчика завершения пытаются получить к нему доступ одновременно (мы поговорим больше о гонках данных в разделе Actors). Во-вторых, нам нужно отложить вызов обработчика завершения функции до тех пор, пока все сетевые задачи не завершатся. Мы делаем это, ожидая DispatchGroup, который отслеживает, сколько “дочерних задач” все еще в процессе. Мы не можем ждать синхронно, потому что не хотим блокировать текущий поток, поэтому мы должны отправить ожидание в отдельную очередь. В нашей версии структурированного параллелизма группа задач обрабатывает всю эту ручную бухгалтерию и синхронизацию за нас.
Далее давайте рассмотрим самые важные правила для групп задач, как мы это сделали для async let:
Результаты дочерних задач доставляются в порядке завершения, а не в порядке подачи. Поэтому ваш пример возвращает словарь, который сопоставляет идентификатор эпизода с данными изображения — а не просто обычный массив. В качестве альтернативы функция могла бы переупорядочить результаты дочерних задач по мере их поступления из группы задач.
Все дочерние задачи должны иметь один и тот же тип результата. Это должно быть так, потому что
TaskGroupдолжен иметь единый тип элемента при доставке результатов. Если вам нужно запустить дочерние задачи с различными типами результатов, у вас есть три варианта: 0. Используйтеasync let, если количество дочерних задач статично.- Используйте несколько групп задач для дочерних задач различных типов элементов.
- Определите общий тип, который может представлять все результаты, например, перечисление с одним случаем для каждого типа дочерней задачи.
Дочерние задачи, добавленные в группу, не могут пережить область видимости замыкания группы задач. Если при выходе из замыкания группы задач все еще есть незавершенные дочерние задачи, среда выполнения неявно дождется их завершения (и отбросит их результаты) перед продолжением. Это снова следует из правила структурированного параллелизма, согласно которому срок службы дочерней задачи связан с ее областью видимости.
В отличие от async let, группа задач не будет неявно отменять незавершенные дочерние задачи при выходе (если только группа задач не выбрасывает ошибку). Если мы не хотим такого поведения, мы должны вызвать TaskGroup.cancelAll(), чтобы вручную отменить все оставшиеся задачи. Неотмена по умолчанию упрощает использование группы задач для инициации задач, возвращающих Void, например:
await withThrowingTaskGroup(of: Void.self) { group in
for document in modifiedDocuments {
group.addTask { try await document.save() }
}
// Неявно ожидает все дочерние задачи.
}
Эта группа задач вообще не собирает результаты своих дочерних задач (Void), но замыкание группы не вернется, пока все дочерние задачи не завершатся. Обратите внимание, что группа тихо отбросит любую ошибку, выброшенную дочерней задачей, так что это не лучший шаблон для задач, которые могут завершиться с ошибкой. Следующий измененный пример проверяет наличие ошибок, но имеет заметно другую семантику:
try await withThrowingTaskGroup(of: Void.self) { group in
for document in modifiedDocuments {
group.addTask { try await document.save() }
}
// Явно ожидаем дочерние задачи, ловим ошибки.
for try await _ in group {
// Ничего не делаем.
}
}
Цель цикла for try await — поймать любую ошибку, выброшенную дочерней задачей, и передать ее вверх. Когда группа задач завершает работу с ошибкой, она неявно отменяет все оставшиеся задачи, так же как и async let. Если мы не хотим этого, мы должны предотвратить попадание ошибок в группу задач, обрабатывая их в замыканиях дочерних задач; обработка их в замыкании группы задач недостаточна, потому что AsyncSequence предписывает, что итерация заканчивается при первой ошибке, и мы пропустим любые последующие ошибки.
Группы задач не ограничивают количество параллелизма. Если мы вызовем функцию loadPosterImages с массивом из 500 эпизодов, она запустит 500 дочерних задач, что потенциально приведет к 500 одновременным сетевым запросам. Для оптимальной производительности, вероятно, лучше ограничить количество параллельных дочерних задач меньшим числом. В настоящее время TaskGroup не предоставляет API для ограничения “ширины” своего параллелизма. Однако мы можем реализовать это самостоятельно: мы начинаем с небольшого количества дочерних задач и немедленно ожидаем их результатов. Затем, каждый раз, когда группа задач доставляет результат, мы можем запустить другую дочернюю задачу, пока все входные данные не будут обработаны. Добавление новых задач в группу, пока она уже производит результаты, является допустимым и ожидаемым.
Sendable Types and Functions Link to heading
Тип замыкания, передаваемого в TaskGroup.addTask, — это @escaping @Sendable () async -> ChildTaskResult. Атрибут @Sendable описывает, что эта функция передается через контексты параллельного выполнения — здесь это родительская и дочерняя задачи — и, следовательно, должна быть безопасной для конкурентного доступа. Замыкания Sendable имеют некоторые ограничения (проверяемые компилятором) при захвате состояния, чтобы гарантировать, что они не вводят гонки данных:
→ Все захваты должны быть сами по себе sendable. Типы могут объявить себя sendable, соответствуя маркерному протоколу Sendable, который является пустым протоколом без требований. Непубличные структуры и перечисления неявно соответствуют Sendable, если все их компоненты являются sendable. Акторы также являются sendable по умолчанию, поскольку они спроектированы для защиты своего состояния от конкурентного доступа.
Для изменяемых или не финальных классов компилятор не может проверить, безопасен ли класс для совместного использования между доменами параллелизма, поэтому он отклоняет простое соответствие Sendable. Мы все еще можем сделать такой класс sendable, написав class C: @unchecked Sendable, что отключает проверки компилятора. Теперь мы несем ответственность за то, чтобы убедиться, что класс безопасен для потоков, например, защищая все доступы к состоянию с помощью блокировки.
→ Все захваты должны быть по значению, в то время как по умолчанию Swift захватывает состояние по ссылке. Захваты неизменяемых значений, объявленных с помощью let, неявно являются по значению; любой другой захват должен быть явно сделан по значению через список захвата. Обратите внимание, что это второе правило исключает любой захват изменяемого состояния. Например, эта альтернативная формулировка нашего кода группы задач является незаконной, поскольку замыкания дочерней задачи захватывают изменяемое состояние родительской задачи:
return await withThrowingTaskGroup(of: (id: Episode.ID, image: Data).self) { group in
var result: [Episode.ID: Data] = [:]
for episode in episodes {
group.addTask {
let (imageData, _) = try await session.data(from: episode.poster_url)
// Ошибка: Мутация захваченной переменной 'result' в коде с параллельным выполнением
result[episode.id] = imageData
}
}
return result
}
Здесь мы пытаемся мутировать словарь result параллельно из дочерних задач, что приводит к гонке данных. Это та же проблема, которую мы обсуждали выше для версии функции loadPosterImages, основанной на обработчике завершения. Однако, в отличие от предыдущего случая, аннотация @Sendable на замыкании позволяет компилятору сразу же поймать эту ошибку. Мы уже видели правильный способ написать это: использовать второй цикл для итерации по результатам дочерних задач. Поскольку этот цикл выполняется в родительской задаче, он может свободно мутировать локальное состояние.
Те же ограничения @Sendable применимы к async let. Вы можете рассматривать правую сторону присваивания async let как обернутую в замыкание @Sendable, которое выполняется в другом контексте выполнения и, следовательно, не может получить доступ к несоответствующему состоянию окружающего контекста или мутировать захваченную переменную.
В Swift 5.6 компилятор еще не ловит все возможные нарушения Sendable. Это связано с тем, что существующие библиотеки (включая фреймворки Apple) еще не были проверены и аннотированы на безопасность параллелизма, поэтому компилятор не может знать, какие типы безопасны для передачи между доменами параллелизма. Если любое использование, например, Date или Data в функции sendable вызвало ошибку из-за отсутствующей аннотации в Foundation, это было бы почти невозможно для разработчиков, чтобы принять async/await.
Флаг компилятора -Xfrontend-warn-concurrency, который мы упомянули во введении к этой главе, включает более строгую диагностику компилятора для нарушений Sendable. Мы нашли дополнительные предупреждения полезными для понимания, когда асинхронная функция выполняется в другом контексте параллелизма и как компилятор предотвращает гонки данных. Мы считаем, что это хорошая идея — включить предупреждения, по крайней мере временно, при написании асинхронного кода, даже если вы решите отключить их снова из-за слишком большого количества шума. Также возможно ретроактивно сделать типы сторонних библиотек sendable с помощью небезопасного расширения, например:
// Должно быть безопасно, потому что Date — это простое значение.
extension Date: @unchecked Sendable {}
Но обратите внимание, что это по своей сути небезопасно, потому что мы говорим компилятору отключить проверку параллелизма для этого типа. Тем не менее, это может быть рабочим временным решением, чтобы заглушить компилятор.
На момент написания вся тема проверки Sendable все еще находится в процессе изменений. В конечном итоге, в Swift 6, нарушения Sendable станут жесткими ошибками. В переходный период (который может занять годы, так как нам нужно дождаться, пока все библиотеки будут проверены) существует путь миграции, который позволяет как авторам модулей сделать свой код совместимым с до- и после-параллельными мирами, так и клиентам избирательно заглушать предупреждения на основе каждого модуля. Клиенты могут использовать директиву @preconcurrency import A, чтобы подавить предупреждения о параллелизме для типов из модуля A. Компилятор достаточно умен, чтобы снова вывести предупреждения, как только модуль A будет обновлен с аннотациями Sendable.
Отмена Link to heading
Возможность отмены асинхронной операции является общим требованием для почти любой программы. Тем не менее, как и обработка ошибок, реализация отмены является сложной задачей в традиционном коде, основанном на обработчиках завершения. Рассмотрим функцию loadFirstPosterCont из раздела Async/Await в качестве примера. Вот что нам нужно изменить, чтобы поддержать отмену:
→ Предоставить способ для вызывающего кода сигнализировать о том, что операция была отменена. Мы могли бы сделать это, вернув потокобезопасный “токен отмены”, который предоставляет публичный метод cancel и позволяет нам запрашивать состояние отмены. Объект URLSessionDataTask сам по себе не подходит — у него есть метод cancel, но как только сетевая задача завершена, ее больше нельзя отменить, поэтому мы не можем использовать его для надежной отмены внутренней задачи загрузки.
→ Когда токен отмены получает запрос на отмену, отмените операции сессии URL. Обычно это делается путем передачи замыкания, которое выполняется при отмене, в токен отмены. Необходимо дополнительное ведение учета, чтобы передать внутреннюю задачу загрузки этому обработчику отмены.
→ Позаботьтесь о том, чтобы не запускать новые задачи после того, как операция была отменена. В нашем примере мы должны вставить ручную проверку на отмену после (возможно, времязатратного) этапа декодирования JSON и вернуть результат раньше, если это необходимо.
→ Убедитесь, что вы возвращаете подходящий код ошибки вызывающему коду при отмене. URLSession завершает работу с кодом ошибки URLError.Code.cancelled при отмене; нам нужно будет придумать собственную ошибку для ручной проверки отмены и, возможно, преобразовать ошибку URL в что-то более подходящее для наших нужд.
Получившийся код выглядит запутанным, что делает намерение кода трудным для понимания, поскольку большая часть является шаблонным кодом для обработки отмены и ошибок. Сравните это с соответствующим асинхронным кодом, который поддерживает отмену “из коробки”:
func loadFirstPoster() async throws -> Data {
let session = URLSession.shared
let (data, _) = try await session.data(from: Episode.url)
let episodes = try JSONDecoder().decode([Episode].self, from: data)
return try await session.data(from: episodes[0].poster_url).0
}
Когда задача, выполняемая этой функцией, отменяется, функция завершится с ошибкой. Мы подробнее рассмотрим, как это работает ниже.
Cancellation Is Cooperative Link to heading
Мы ранее видели, что система параллелизма Swift является кооперативной, и это также относится к отмене: отмена задачи не имеет эффекта, если код, выполняющийся в задаче, периодически не проверяет, была ли она отменена, и не завершает выполнение досрочно, если это необходимо. Важной целью проектирования системы является предоставление функциям возможности очистки после себя при отмене. Именно поэтому система не может просто завершить отмененную задачу. Единицей, которая отменяется, всегда является задача, а не конкретная функция в этой задаче. Если у вас есть доступ к дескриптору задачи (Task handle) неструктурированной задачи, вы можете вызвать ее метод cancel для отмены. Также возможно отменить текущую задачу с помощью следующего кода:
withUnsafeCurrentTask { task in
task?.cancel()
}
На самом базовом уровне отмена задачи просто устанавливает флаг в метаданных задачи. Функция, которая хочет поддерживать отмену, должна периодически проверять Task.isCancelled или вызывать try Task.checkCancellation() — последний вариант прерывает выполнение с подходящей ошибкой, если isCancelled равно true. Добавление этих проверок в нашу примерную функцию может выглядеть так:
func loadFirstPoster2() async throws -> Data {
try Task.checkCancellation()
let session = URLSession.shared
let (data, _) = try await session.data(from: Episode.url)
try Task.checkCancellation()
let episodes = try JSONDecoder().decode([Episode].self, from: data)
try Task.checkCancellation()
return try await session.data(from: episodes[0].poster_url).0
}
Это не так уж плохо, но в этом примере это даже не обязательно, потому что API URLSession, которые мы вызываем, уже выполняют подобные проверки отмены внутри себя. Если задача отменена, текущая активная загрузка остановится и выбросит ошибку, которую наша функция передаст вызывающему коду. В зависимости от того, насколько велики модели данных, которые мы ожидаем загрузить, шаг декодирования JSON между двумя сетевыми запросами может быть проблематичным, потому что JSONDecoder в настоящее время не поддерживает отмену. Поэтому, если декодирование JSON занимает, скажем, 500 миллисекунд, наша функция может продолжать выполняться в течение этого времени после получения сигнала об отмене, что не идеально. Чтобы исправить это, нам нужно использовать декодер JSON с поддержкой отмены или написать свой собственный.
Стоит отметить, что JSONDecoder может поддерживать отмену, даже если это полностью синхронный API. Синхронные функции могут использовать тот же вызов Task.isCancelled или try Task.checkCancellation() для проверки отмены — они сделают правильное действие, если синхронная функция вложена в асинхронный контекст, и вернут значения по умолчанию (false) в противном случае.
В заключение, в общем случае, когда мы пишем функцию, которая проводит большую часть своего времени, вызывая другие (системные) API, которые уже обрабатывают отмену, мы получаем поддержку отмены фактически бесплатно, при условии, что мы передаем ошибки отмены нашему вызывающему коду. Однако, если мы выполняем длительные вычисления или вызываем несколько неотменяемых API последовательно, нам следует добавить ручные проверки отмены в наш код. Например, следующая функция вычисляет потенциально большое количество случайных чисел. Мы добавляем проверку на отмену на каждой тысячной итерации цикла:
func makeRandomNumbers(in range: ClosedRange<Int>, count: Int) throws -> [Int] {
var result: [Int] = []
result.reserveCapacity(count)
for i in 1...count {
// Проверяем на отмену периодически.
if i.isMultiple(of: 1000) {
try Task.checkCancellation()
}
result.append(Int.random(in: range))
}
return result
}
Если бы это была асинхронная функция, мы также хотели бы периодически вызывать Task.yield() таким же образом, чтобы дать времени выполнения шанс запланировать другую задачу на нашем потоке.
Cancellation Использует Путь Ошибки Link to heading
По соглашению, функция, которая обнаруживает, что она была отменена, должна выбрасывать CancellationError, который является новым типом стандартной библиотеки. Вызывающие функции могут проверять эту ошибку, чтобы отличить отмену от других ошибок. Использование ошибок для отмены имеет преимущество в том, что отмена не вводит новых путей управления, но обратите внимание, что конкретная ошибка является лишь соглашением — вызывающие функции не могут на нее полагаться. Например, URLSession продолжает выбрасывать свою обычную ошибку URLError.Code.cancelled при отмене. Если мы не хотим раскрывать поведение URLSession нашим вызывающим функциям, мы можем поймать “неправильную” ошибку и заменить ее на CancellationError:
func loadFirstPoster3() async throws -> Data {
do {
let session = URLSession.shared
let (data, _) = try await session.data(from: Episode.url)
let episodes = try JSONDecoder().decode([Episode].self, from: data)
return try await session.data(from: episodes[0].poster_url).0
} catch URLError.Code.cancelled {
// Заменяем URLError.Code.cancelled.
throw CancellationError()
} catch {
// Прокидываем все другие ошибки.
throw error
}
}
Функции, не выбрасывающие ошибки, также могут участвовать в отмене. Они не могут выбрасывать ошибку, конечно, поэтому авторам необходимо выбрать подходящее “пустое” значение для возврата, такое как nil. В большинстве случаев, вероятно, лучше сделать функцию выбрасывающей ошибку. Отмененные функции даже могут возвращать частичный результат — например, makeRandomNumbers может прерваться, вернув частично заполненный массив до момента, когда она обнаружит, что была отменена. Если вы решите сделать это, убедитесь, что четко задокументировали поведение, так как вызывающие функции могут не ожидать получения частичных результатов.
Отмена и структурированная конкуренция Link to heading
До сих пор мы рассматривали отмену в одной задаче, но настоящая сила системы отмены проявляется в её интеграции со структурированной конкуренцией. Сигналы отмены автоматически распространяются по дереву задач: когда родительская задача отменяется, её дочерние задачи увидят, что их флаг isCancelled переключился на true. Однако это не остановит дочерние задачи немедленно, так как каждая задача отвечает за выполнение периодических проверок на отмену самостоятельно. Кроме того, отмена должна уважать основное правило структурированной конкуренции: дочерние задачи не могут пережить своего родителя — отменённая родительская задача будет продолжать выполняться до тех пор, пока все её дочерние задачи не завершатся.
Распространение отмены по дереву задач означает, что отмена на верхнем уровне задачи (например, в ответ на действие пользователя) сообщит всем дочерним задачам, работающим над этой операцией, прервать выполнение, независимо от того, насколько глубоко они вложены — без единой строки кода. Это невероятно мощно, но это работает только если мы используем структурированную конкуренцию (неструктурированные задачи должны отменяться вручную) и если мы проектируем наши функции с учётом отмены.
В качестве ещё одного примера того, насколько важно проектировать код для отмены, давайте напишем функцию, которая выполняет асинхронную операцию с таймаутом:
func asyncWithTimeout<R>(
nanoseconds timeout: UInt64,
do work: @escaping () async throws -> R
) async throws -> R {
return try await withThrowingTaskGroup(of: R.self) { group in
// Начинаем фактическую работу.
group.addTask { try await work() }
// Запускаем таймер таймаута.
group.addTask {
try await Task.sleep(nanoseconds: timeout)
// Мы достигли таймаута.
throw CancellationError()
}
// Первый, кто достигнет финиша, выигрывает, отменяем другую задачу.
let result = try await group.next()!
group.cancelAll()
return result
}
}
А вот пример использования, который загружает изображение эпизода с таймаутом в одну секунду — если загрузка занимает больше времени, функция завершится с ошибкой CancellationError:
let imageData = try await asyncWithTimeout(nanoseconds: 1_000_000_000, do: loadFirstPoster3)
В asyncWithTimeout мы используем группу задач, чтобы запустить две параллельные дочерние задачи: одну для фактической работы и одну, которая спит в течение заданного времени таймаута. Затем мы ждём, пока первая дочерняя задача не завершится. Если задача работы “выигрывает”, мы вернём её результат и отменим задачу таймаута. Если задача таймаута быстрее, group.next() выбросит CancellationError. Поскольку группа задач завершится с ошибкой, она неявно отменит незавершённую задачу работы.
Вызов group.cancelAll() после получения первого результата из группы важен. Напомним, что группы задач не отменяют неожидаемые дочерние задачи при успешном завершении. Без cancelAll() группа задач будет тихо ждать, пока таймаут не истечёт, прежде чем вернуться. Забыть добавить эту строку — это простая ошибка, потому что она не изменяет вывод вашей программы — только её производительность. На самом деле, авторы предложения Swift Evolution для структурированной конкуренции сделали эту ошибку несколько раз.
Функция asyncWithTimeout также иллюстрирует, насколько важно, чтобы функции поддерживали модель кооперативной отмены. Если таймаут “выигрывает”, группа задач отменит задачу работы, но затем всё равно должна будет дождаться завершения задачи работы. Если задача работы не проверяет отмену (как в примере с JSONDecoder выше) и занимает много времени для завершения, функция asyncWithTimeout может занять гораздо больше времени, чем указанный таймаут.
CancellationHandlers Link to heading
Функция withTaskCancellationHandler(operation:onCancel:) позволяет установить обработчик отмены для текущей задачи. Функция принимает два замыкания: асинхронную операцию (работу), которая выполняется в текущей задаче, и обработчик отмены, который вызывается немедленно, когда текущая задача отменяется. Цель обработчика — выполнить код очистки при отмене, например, для освобождения ресурсов или для передачи сигнала об отмене другим объектам, которые не интегрированы с системой конкурентности Swift.
Например, вероятно, что фреймворк Foundation использует обработчик отмены в своей реализации API URLSession на основе async/await. В этом случае задача замыкания onCancel будет заключаться в том, чтобы передать событие отмены в систему URLSession, чтобы сетевой запрос мог быть отменен.
Реализовать это сложнее, чем кажется, потому что обработчик отмены выполняется в другом контексте конкурентности, чем задача, которая отменяется. В частности, обработчик отмены и основная операция не могут получить доступ к общему изменяемому состоянию (замыкание onCancel помечено как @Sendable, чтобы компилятор был осведомлен об этом). Нам нужно использовать класс с ручной синхронизацией (блокировка или очередь диспетчера), чтобы безопасно передавать экземпляр URLSessionDataTask между двумя контекстами. Мы не можем использовать актор, потому что ни обработчик отмены, ни замыкание продолжения не являются асинхронными. Код слишком длинный, чтобы его печатать здесь, но вы можете ознакомиться с ним на GitHub.
Неструктурированная конкуренция Link to heading
Иногда определяющая особенность структурированной конкуренции — ограничение областью — становится препятствием. Нам нужен другой инструмент, который позволит нам выйти за рамки структурированной конкуренции и начать задачу, которая может пережить текущую область. Этот инструмент — инициализатор Task.init(priority:operation:). Обычно мы вызываем этот инициализатор с завершающим замыканием, вот так:
let task = Task {
return try await loadEpisodesAndCollections()
}
Вызов Task.init запускает новую, независимую задачу, жизненный цикл которой не связан с текущей областью. Задача начинает выполняться немедленно, параллельно с исходной задачей. Новая задача становится корнем отдельного дерева задач, которое будет существовать до завершения задачи. Поскольку между исходной задачей и новой задачей нет родительско-дочерних отношений, мы называем это неструктурированной конкуренцией.
Неструктурированная задача не будет отменена, когда ее исходная задача будет отменена. Экземпляр Task, возвращаемый инициализатором, позволяет нам вручную отменить задачу или получить ее результат, используя try await task.value (оператор try требуется только в том случае, если замыкание задачи может выбросить исключение). Свойство value является асинхронным, потому что задача может еще не завершиться, когда мы получаем ее результат. Экземпляр Task — это версия объекта будущего или обещания в Swift.
Обратите внимание, что запуск неструктурированной задачи из контекста структурированной конкуренции (например, внутри группы задач) часто является антипаттерном, поскольку это позволяет части операции выйти за пределы мира структурированной конкуренции. Это не значит, что создание неструктурированной задачи никогда не является хорошей идеей — это явно необходимо для параллельной работы, которая должна пережить текущую область. Но цена за потерю автоматической отмены, распространения ошибок и управления жизненным циклом значительна. Более того, мы расплачиваемся за неструктурированные задачи потерей ясности, так как иерархия задач больше не очевидна из структуры кода. Ограничения, накладываемые структурированной конкуренцией, намеренно разработаны для того, чтобы сделать наш код более понятным; мы не должны выходить за их пределы, если это не абсолютно необходимо.
Среда выполнения Swift поддерживает сильную ссылку на все задачи до их завершения, поэтому нам не нужно хранить экземпляр задачи только для того, чтобы поддерживать задачу в живых. Также замыкание, переданное в Task.init, не требует явных аннотаций self при сильном захвате self. Это достигается с помощью нового, неофициального атрибута компилятора _implicitSelfCapture. Обоснование отсутствия требования к self заключается в том, что большинство задач не создадут постоянный цикл ссылок, поскольку они в конечном итоге завершатся, в этот момент они освободят захваченные переменные. Если мы создаем задачу, которая потенциально никогда не завершится (например, наблюдатель NotificationCenter, который публикует входящие уведомления как AsyncSequence), мы должны позаботиться о том, чтобы захватывать переменные слабо, если они могут вызвать циклы ссылок.
Неструктурированные и отсоединенные задачи Link to heading
Неструктурированные задачи, запущенные с помощью Task{…}, унаследуют приоритет контекста-источника (если не переопределен), локальные значения задачи и — что важно — изоляцию актора. Поэтому, если функция, запускающая задачу, выполняется на конкретном актере, новая задача также будет изолирована для этого актера. Это часто желательно, поскольку дает неструктурированной задаче доступ к окружающему состоянию, изолированному для актора, как в этом вымышленном примере:
@MainActor
class ViewModel: ObservableObject {
@Published var counter = 0
func incrementAsync() {
Task {
// Задача может получить доступ к состоянию, изолированному для актора.
counter += 1
}
}
}
Мы скажем больше об изоляции акторов в разделе “Акторы” позже в этой главе. Обратите внимание, что если замыкание задачи вызывает другие асинхронные функции с другой изоляцией актора (например, из аннотации @MainActor), задача переключится на другого актера. “Обычные” асинхронные функции без какой-либо привязки к актеру, как правило, выполняются в том контексте актера, который в данный момент активен, но точное поведение не было указано. На момент написания команда Swift работает над формализацией правил, чтобы всегда было статически известно, в каком контексте выполняется функция.
Если мы не хотим, чтобы новая задача выполнялась в текущем контексте выполнения, мы можем запустить отсоединенную задачу, используя Task.detached{…} вместо этого. Отсоединенная задача работает аналогично неструктурированной задаче и имеет тот же API, но не унаследует приоритет текущей задачи, локальные значения задачи или изоляцию актора.
Вы можете задаться вопросом, когда следует использовать отсоединенную задачу вместо неструктурированной. Наш совет — рассматривать Task{…} как выбор по умолчанию и использовать Task.detached только в том случае, если у вас есть конкретная причина покинуть текущий контекст актора. Одной из таких причин является то, если вы находитесь на главном актере (об этом позже) и хотите запустить операцию, которая не должна занимать главный поток.
В оставшейся части этой главы мы будем использовать термин “неструктурированная задача” как общее обозначение как для неструктурированных, так и для отсоединенных задач. Мы можем игнорировать это различие, если не будем специально обсуждать изоляцию акторов.
Запуск асинхронной работы из синхронных контекстов Link to heading
Несструктурированные задачи могут быть запущены из любого контекста — даже из неасинхронной функции. На самом деле, Task{…} и Task.detached{…} — это единственные способы вызвать асинхронную функцию из асинхронного контекста. Мы иногда говорим, что атрибут async является “заразным”, потому что сделать функцию асинхронной требует, чтобы все её вызывающие функции также стали асинхронными.
Каждая асинхронная функция, которая выполняется, должна иметь несструктурированную задачу в корне текущего дерева задач. Эта задача может быть скрыта в библиотеке третьей стороны или в среде выполнения Swift, но она там есть. Рассмотрим этот пример одной из самых простых программ с использованием async/await, которые мы можем написать:
@main struct Program {
static func main() async throws {
print("Сплю одну секунду")
try await Task.sleep(nanoseconds: 1_000_000_000)
print("Готово")
}
}
Когда мы объявляем функцию main() исполняемого файла как асинхронную, компилятор генерирует код, который вызывает функцию _runAsyncMain внутри среды выполнения Swift. Если вы посмотрите на исходный код этой функции, вы найдете вызов Task.detached там.
Обработка Ошибок для Неструктурированных Задач Link to heading
Как мы подробно обсудим в главе об обработке ошибок, одной из основных целей дизайна Swift является усложнение игнорирования ошибок программистами — мы должны либо обрабатывать ошибку на месте, либо явно передавать её нашему вызывающему коду, который затем должен обработать или распространить её и так далее. К сожалению, API неструктурированных задач открывает новую лазейку, которая делает слишком простым игнорирование ошибок в определённых случаях. В качестве примера рассмотрим следующую неструктурированную задачу, которая сохраняет текущее состояние программы на диск:
Task {
try await save()
}
Если save выбрасывает ошибку, эта ошибка будет тихо проигнорирована, потому что никто никогда не ожидает результат этой задачи. В идеале компилятор должен распознать это и заставить нас обработать случай с ошибкой. Мы даже не получаем предупреждение, потому что инициализатор Task объявлен с атрибутом @discardableResult, который позволяет нам игнорировать его возвращаемое значение. Без @discardableResult нам хотя бы пришлось бы написать _ = Task { ... }, что сделало бы природу задачи “забудь и иди дальше” явной.
Акторы Link to heading
Параллельное программирование должно справляться с присущей проблемой потенциальных конфликтов при доступе к общим ресурсам, независимо от того, используете ли вы традиционные потоки, очереди GCD или задачи Swift. Каждый раз, когда код выполняется одновременно (в режиме разделяемого времени или параллельно), необходимо обеспечить защиту общих ресурсов, таких как свойства объекта, память в целом, файлы или соединение с базой данных.
Традиционно для этой цели использовалось множество различных API для блокировок, а в последнее время очереди GCD стали популярным способом обеспечения бесконфликтного доступа к общим ресурсам. С введением нативной модели параллелизма Swift язык получил новый механизм для изоляции ресурсов: акторы.
Resource Isolation Link to heading
Актор — это тип ссылки, который защищает свое состояние, позволяя доступ к своим изменяемым свойствам только непосредственно через себя (исключение: константы могут безопасно использоваться за пределами акторов) и изолируя выполнение своих частей функций в контексте последовательного выполнения. Важно помнить, что актор не изолирует выполнение целых методов: общее правило заключается в том, что только части функций между точками приостановки могут считаться атомарными операциями. В особом случае метода, который не вызывает другие асинхронные функции с помощью await, метод выполняется как единое задание и становится атомарной операцией (мы подробнее обсудим это в разделе о повторной входности).
Модель доступа акторов обеспечивает изоляцию ресурсов для предотвращения гонок данных. Гонка данных происходит, когда два потока пытаются одновременно получить доступ к одной и той же области памяти, и хотя бы один из них выполняет операцию записи. Это может привести к чтению бессмысленных данных, например, если поток чтения считывает из памяти в вопросе в середине операции записи другого потока. Еще хуже, если два потока записывают в одно и то же место в памяти одновременно, это может привести к повреждению данных.
Акторы обеспечивают защиту от гонок данных на своих свойствах. Модель доступа акторов, как описано выше, строго предотвращает одновременный доступ к свойству двумя потоками, независимо от того, идет ли речь о чтении или записи. Однако обратите внимание, что использование акторов не сделает ваш параллельный код магически корректным — гонки данных являются лишь одним из типов более широкой категории условий гонки.
Условия гонки возникают, когда правильное поведение нашего кода зависит от последовательности выполнения нескольких потоков. В контексте акторов можно сказать, что условие гонки возникает, когда правильное поведение актора зависит от последовательности выполнения нескольких изолированных частей функций.
Хотя изолированная природа каждой части функции предотвращает гонки данных, как описано выше, у нас нет контроля над последовательностью выполнения нескольких частей функций между точками приостановки. Например, изменение свойства актора перед await и полагание на то, что это свойство все еще будет иметь то же значение после await, является условием гонки. Последствия условия гонки зависят от конкретных обстоятельств, но это может привести к непредвиденному поведению или даже к повреждению данных (на более высоком уровне, чем фактическое повреждение области памяти, как в случае гонки данных).
Ошибки, возникающие из-за условий гонки — также называемые хейзенбагами — особенно трудно отлаживать из-за их недетерминированной природы. Поэтому важно осознавать возможность условий гонки, даже в акторах, и избегать их с помощью тщательного проектирования. Мы обсудим, почему актор не защищает вас от условий гонки в разделе о повторной входности.
С этим базовым пониманием безопасности, которую акторы обеспечивают и не обеспечивают, давайте рассмотрим пример. Представим, что мы хотим реализовать очередь для веб-краулера, которая выглядит следующим образом:
class CrawlerQueue {
var pending: [URL] = []
var finished: [URL: String] = [:]
}
Предположим, что веб-краулер использует несколько параллельных рабочих процессов для обработки ожидающих URL, и мы хотим поделиться очередью краулера между всеми рабочими процессами. Рабочий процесс должен иметь возможность извлекать ожидающий URL из очереди, добавлять новые найденные URL в очередь и сохранять результат краулинга для обработанного URL. Это делает экземпляр CrawlerQueue с его двумя свойствами, pending и finished, общим ресурсом среди параллельных рабочих процессов, который необходимо защитить от гонок данных.
Используя очереди диспетчера, мы могли бы реализовать необходимую синхронизацию следующим образом:
class CrawlerQueue {
private var pending: Set<URL> = []
private var finished: [URL: String] = [:]
private var queue = DispatchQueue(label: "crawler-queue")
public func getURL() -> URL? {
queue.sync {
self.pending.popFirst()
}
}
public func enqueue(_ url: URL) {
queue.async {
guard self.finished[url] == nil else { return }
self.pending.insert(url)
}
}
public func store(_ contents: String, for url: URL) {
queue.async {
self.finished[url] = contents
}
}
}
Все свойства теперь являются приватными для очереди и могут быть доступны только через публичные методы, которые используют частную последовательную очередь диспетчера для предотвращения гонок данных на свойствах pending и finished.
Реализация акторов для очереди выглядит аналогично, за исключением кода для диспетчеризации на частную последовательную очередь:
actor CrawlerQueue {
var pending: Set<URL> = []
var finished: [URL: String] = [:]
public func getURL() -> URL? {
pending.popFirst()
}
public func enqueue(_ url: URL) {
guard finished[url] == nil else { return }
pending.insert(url)
}
public func store(_ contents: String, for url: URL) {
finished[url] = contents
}
}
Свойства pending и finished могут быть доступны только в методах актора по умолчанию (свойства изолированы в контексте выполнения актора), и каждый метод актора выполняется на последовательном исполнителе актора, что гарантирует, что мы не столкнемся с гонками данных на его свойствах.
Рекурсивность Link to heading
Вместо того чтобы предоставлять отдельные методы в CrawlerQueue для получения URL, добавления новых URL в очередь и хранения результата для URL, давайте предоставим унифицированный API для всех этих задач, чтобы мы случайно не забыли один из вызовов:
actor CrawlerQueue {
var pending: Set<URL> = []
var finished: [URL: String] = [:]
func process( _ handler: (URL) async -> (contents: String, links: [URL])) async {
**guard let** url = pending.popFirst() else { return }
let (contents, links) = await handler(url)
finished[url] = contents
for link in links {
guard finished[link] == nil else { continue }
pending.insert(link)
}
}
}
Метод process вызывается с асинхронной функцией в качестве параметра. Эта функция принимает URL для обработки и возвращает результат в виде строки и массива новых URL, которые нужно добавить в очередь.
Метод process явно помечен как async, потому что в противном случае мы не смогли бы вызвать асинхронный обработчик с await. Как только мы вызываем await handler(url), Swift имеет два несовершенных варианта, как продолжить: либо он может позволить другому коду выполняться на акторе, пока ожидается результат обработчика, либо он может предотвратить выполнение другого кода актора до тех пор, пока обработчик не вернется.
В последнем случае мы могли бы легко столкнуться с взаимными блокировками. Например, было бы достаточно, чтобы функция обработчика вызвала любой другой метод на акторе CrawlerQueue, чтобы остановить выполнение. По этой причине Swift выбирает рекурсивность в качестве поведения по умолчанию для акторов: как только текущая задача приостановлена (когда мы вызываем await handler(url)), другой код может выполняться на акторе до тех пор, пока задача не возобновится. Если функция обработчика сделает другой вызов к актеру внутри себя, выполнение кода все равно может продолжаться.
С учетом того, что рекурсивность является поведением по умолчанию, важно убедиться, что мы не полагаемся на предположения о состоянии актора, остающемся неизменным до и после точки приостановки. Точно так же мы не можем предполагать, что асинхронный метод выполняется как атомарная операция. Например, наша очередь задач находится в своеобразном состоянии, пока process ждет завершения обработчика: URL, который обрабатывается, был удален из набора ожидающих URL, но он еще не был добавлен в словарь завершенных задач. Следовательно, у нас есть окно возможностей для того, чтобы тот же URL снова был добавлен в ожидающие URL, хотя он в данный момент обрабатывается.
Эта проблема может проявиться только потому, что метод process актора является рекурсивным — если бы методы актора не были рекурсивными, никакой другой код на актере не мог бы вмешиваться, пока выполняется метод process, и нам не пришлось бы беспокоиться о том, что состояние актора изменится, ожидая точки приостановки.
Чтобы исправить эту ситуацию, мы можем добавить третье свойство в очередь, чтобы отслеживать URL, которые в данный момент обрабатываются:
actor CrawlerQueue {
var pending: Set<URL> = []
var inProcess: Set<URL> = []
var finished: [URL: String] = [:]
func process( _ handler: (URL) async -> (String, [URL])) async {
**guard let** url = pending.popFirst() else { return }
inProcess.insert(url)
let (contents, links) = await handler(url)
finished[url] = contents
inProcess.remove(url)
for link in links {
guard finished[link] == nil, !inProcess.contains(link) else { continue }
pending.insert(link)
}
}
}
Как только URL находится в очереди, он всегда отслеживается так или иначе — либо как ожидающий, в процессе, или завершенный. Следовательно, process теперь может безопасно выполняться в рекурсивном режиме.
ActorPerformance Link to heading
Для внешнего мира все публичные методы актора являются асинхронными методами: их необходимо вызывать с использованием await, чтобы обеспечить переключение в собственный контекст выполнения актора. Например, функция ниже последовательно вызывает двух акторов. Мы говорим, что задача “перепрыгивает” между акторами:
// Counter — это актер.
let counter1 = Counter()
let counter2 = Counter()
func incrementAll() async {
await counter1.increment()
await counter2.increment()
}
Перепрыгивание между акторами обязательно имеет некоторые накладные расходы, но система параллелизма Swift разработана так, чтобы сделать перепрыгивание между акторами намного быстрее, чем переключение потоков или очередей диспетчеров, поэтому вам не нужно слишком беспокоиться о стоимости производительности. Поскольку задача, которая вызывает метод актора (задача, выполняющая функцию incrementAll в нашем примере), должна ждать завершения метода актора, актер может выполняться в текущем потоке задачи. Если актер не занят в момент вызова, перепрыгивание актора состоит в установке флага в актере, который “блокирует” его для других вызывающих, а затем выполняет обычный вызов функции. Когда метод возвращает результат, актер снова “разблокируется”. Переключение контекста становится более дорогим при наличии конкуренции, т.е. если актер в данный момент выполняет какую-то другую задачу. В этом случае наша задача должна приостановиться и уступить свой поток, пока ждет, когда актер станет доступен. Вы получите наилучшие показатели производительности, если сможете спроектировать свою программу таким образом, чтобы акторам можно было ожидать, что они будут свободны большую часть времени.
Стоимость перепрыгивания между акторами также увеличивается при переключениях к и от главного актора. Поскольку главный актер выполняется в главном потоке, перепрыгивание из другого контекста выполнения (который, как правило, выполняется на потоке кооперативного пула потоков) к главному актеру или наоборот также включает относительно дорогое переключение потоков.
MainActor Link to heading
Иногда нет необходимости создавать собственный актор для синхронизации доступа к какому-либо состоянию. Особенно в GUI-приложениях вы часто просто хотите убедиться, что определенный метод вызывается в основном потоке или что конкретное свойство доступно только из основного потока. Поскольку основной поток (или основная очередь в терминах GCD) всегда играл особую роль на платформах Apple, теперь он также представлен в виде глобального актора — MainActor. Внутри, MainActor использует основной поток в качестве своего контекста последовательного выполнения.
Особенность глобального актора заключается в том, что его можно использовать в качестве атрибута для аннотирования других типов, свойств или функций. Например, вместо того чтобы определять CrawlerQueue как актор, мы также можем использовать системно определенный атрибут @MainActor, чтобы убедиться, что его методы и свойства доступны только из основного потока:
@MainActor
final class CrawlerQueue {
var pending: Set<URL> = []
var inProcess: Set<URL> = []
var finished: [URL: String] = [:]
func process(_ handler: (URL) async -> (String, [URL])) async {
// ...
}
}
Конечно, эта реализация CrawlerQueue будет выполнять больше работы в основном потоке по сравнению с ее аналогом на основе актора, но особенно для публикации изменений в UI эта техника очень полезна.
Пометить целый тип как @MainActor — это сокращение для пометки всех свойств и методов индивидуально. Пример выше семантически эквивалентен следующему:
final class CrawlerQueue {
@MainActor var pending: Set<URL> = []
@MainActor var inProcess: Set<URL> = []
@MainActor var finished: [URL: String] = [:]
@MainActor func process(_ handler: (URL) async -> (String, [URL])) async {
// ...
}
}
Иногда вы сталкиваетесь с ситуацией, когда хотите пометить целый тип как @MainActor, с одним или двумя исключениями. Вместо того чтобы вручную вводить все аннотации, мы также можем использовать обратный подход: аннотировать тип как @MainActor, а затем использовать ключевое слово nonisolated, чтобы отказаться от изоляции main actor для конкретных методов или свойств. Например, мы могли бы использовать эту технику, чтобы добавить не изолированный инициализатор к иначе изолированному main actor CrawlerQueue:
@MainActor
final class CrawlerQueue {
nonisolated init(_ initialURLs: [URL]) {
self.pending = Set(initialURLs)
}
// ...
}
В большинстве случаев атрибут @MainActor делает то, что вы ожидаете: он гарантирует, что аннотированный метод или свойство доступно только из основного потока. Однако есть некоторые тонкие крайние случаи, о которых стоит подумать. В частности, есть три различных сценария, которые нам нужно рассмотреть: аннотирование асинхронного метода с @MainActor, аннотирование неасинхронных методов и аннотирование свойств (которые можно рассматривать как неасинхронные геттеры и сеттеры).
Асинхронные @MainActor методы: Когда мы аннотируем асинхронный метод как @MainActor, мы можем быть уверены, что код этого метода выполняется в основном потоке. Компилятор заставляет нас вызывать асинхронный метод с await, тем самым предоставляя ему возможность переключиться на контекст выполнения main actor. Это будет работать даже если такой метод вызывается из кода Objective-C, потому что асинхронные функции представлены как функции с обработчиками завершения, позволяя среде выполнения переключать контекст выполнения.
Неасинхронные @MainActor методы: Когда мы аннотируем неасинхронный метод как @MainActor, ситуация немного сложнее. Компилятор все равно заставит нас либо не вызывать этот метод из чего-либо, кроме контекста выполнения main actor, либо вызывать его с await из любого другого контекста. Однако, поскольку метод синхронный, его нельзя отправить в правильный контекст выполнения во время выполнения. Все проверки должны выполняться на этапе компиляции, и их можно обойти, например, из кода Objective-C.
Один из распространенных примеров этой проблемы возникает, когда мы подгоняем класс под протокол и затем помечаем метод протокола как @MainActor. Мы можем предположить, что это гарантирует, что метод всегда выполняется в main actor, но это не обязательно так. Например, делегатные методы URLSession вызываются в частной очереди сессии. Аннотирование такого делегатного метода как @MainActor не имеет эффекта относительно потока, в котором он выполняется, и компилятор не может предупредить нас об этом.
- @MainActor свойства: Аннотации @MainActor на свойствах также являются проверкой на этапе компиляции. Если мы попытаемся получить доступ к свойству, изолированному в main actor, из другого контекста выполнения, компилятор предотвратит это. В отличие от синхронных вызовов методов, изолированных в main actor, мы не можем вставить await перед доступом к свойству, чтобы позволить переключение контекста. Мы должны убедиться, что уже находимся в основном контексте выполнения; в противном случае компилятор выдаст ошибку. Так же, как и с синхронными методами, важно помнить, что проверки компилятора вокруг свойств, изолированных в main actor, могут быть обойдены.
Размышления о контекстах выполнения Link to heading
Когда вы читаете код на Swift, который использует GCD для параллелизма, вы размышляете о том, какой код выполняется в какой очереди, по сути, «исполняя» код «в своей голове», прослеживая его путь от dispatch до dispatch во время выполнения. Кроме того, вам придется обращаться к документации API, предоставляемым Apple или сторонними разработчиками, чтобы определить, вызываются ли обработчики завершения или методы делегатов в главной очереди или в какой-то другой очереди. В этой модели, в какой очереди вы находитесь в данный момент, определяется во время выполнения. Если вы посмотрите на какой-либо конкретный фрагмент кода, вы не можете с уверенностью сказать, в какой очереди он будет выполняться, когда код будет исполняться (по крайней мере, если код, который вы рассматриваете, не выполняет собственное распределение прямо в этот момент).
С моделью изолированных функций в Swift процесс размышления больше не зависит исключительно от поведения во время выполнения. Вы можете определить, в каком контексте выполнения актора будет выполняться асинхронная функция, проверяя ее лексическую область. Если метод определен внутри актора, вы знаете, что этот код будет выполняться изолированно в контексте выполнения этого актора, независимо от того, что происходит вокруг него во время выполнения. Аналогично, любая асинхронная функция, помеченная как изолированная для актора, например, с атрибутом @MainActor, гарантированно будет выполняться изолированно в этом актере. Если асинхронная функция, которую вы рассматриваете, не является явно изолированной для актора ни одним из этих двух способов, она будет выполняться на общем исполнителе, не связанном с каким-либо актером (в конечном итоге, как подробно описано в этом предложении).
Однако, если вы рассматриваете асинхронную функцию, контекст выполнения все еще определяется во время выполнения: вам нужно проследить ее потенциальные стеки вызовов до ближайших асинхронных функций, чтобы знать, в каком контексте выполнения она может быть выполнена.
Резюме Link to heading
Модель параллелизма является самым значительным изменением в языке с момента введения расширений протоколов в Swift 2. Async/await позволяет нам писать асинхронный код в том же стиле, что и синхронный код, используя обычные конструкции управления потоком языка. Структурированный параллелизм применяет 60-летние идеи структурного программирования к параллельному коду, а актеры предотвращают гонки данных, сериализуя доступ к памяти или другим ресурсам.
Ни одно из этих понятий само по себе не является новым, но Swift объединяет их в одну согласованную модель параллелизма и глубоко интегрирует их в язык. Это позволяет компилятору проверять многие распространенные ошибки параллелизма, что, надеемся, приведет к меньшему количеству ошибок и более безопасным программам.
На момент написания этой главы Swift 5.5 всего несколько месяцев. Некоторые вещи все еще находятся в процессе изменения, и у нас, естественно, нет большого опыта использования новой модели параллелизма в производстве. Работая с новыми функциями во время написания этой главы, мы постоянно узнавали что-то новое. Мы считаем справедливым предположить, что нам потребуется еще год или два реального использования, чтобы действительно понять новую модель. Мы уверены в том, что то, что мы написали в этой главе, является правильным и надежным советом, но также уверены, что наше понимание будет продолжать развиваться.
Обработка ошибок Link to heading
13 Link to heading
Как программисты, мы постоянно сталкиваемся с тем, что что-то идет не так: сетевое соединение может упасть, файл, который мы ожидали, может не существовать, и так далее. Умение хорошо обрабатывать ошибки — это один из тех неосязаемых факторов, который отличает хорошие программы от плохих, и все же мы часто склонны рассматривать обработку ошибок как второстепенную задачу — что-то, что можно добавить позже (и что затем часто вырезается, когда приближается срок сдачи). И мы это понимаем: обработка ошибок может быть запутанной, а кодирование для «счастливого пути» обычно более увлекательно. Поэтому особенно важно, чтобы язык программирования предоставлял хорошую модель, которая поддерживает программистов в этой задаче. Вот некоторые из возможностей, которые предоставляет встроенная архитектура обработки ошибок Swift с помощью throw, try и catch:
→ Безопасность — Swift делает невозможным случайное игнорирование ошибок программистами.
→ Сжатие — Код для выбрасывания и перехвата ошибок не подавляет код для «счастливого пути».
→ Универсальность — Один механизм выбрасывания и обработки ошибок может использоваться повсюду, включая асинхронный код. Новый паттерн async/await для асинхронных функций полностью поддерживает обработку ошибок на основе throw/try. (До async/await распространенный идиом использования обратных вызовов для параллелизма не интегрировался с нативным подходом обработки ошибок языка.)
→ Пропаганда — Ошибка не должна обрабатываться там, где она произошла, потому что логика восстановления от ошибки часто находится далеко от места, где ошибка возникла. Архитектура обработки ошибок Swift упрощает передачу ошибок вверх по стеку вызовов на соответствующий уровень. Промежуточные функции (функции, которые вызывают выбрасывающие функции, но сами не выбрасывают и не обрабатывают ошибки) могут передавать ошибки без необходимости в больших изменениях синтаксиса.
→ Документация — Компилятор требует, чтобы как выбрасывающие функции, так и их места вызова были аннотированы, что упрощает программистам понимание, где могут возникать ошибки. Однако типовая система не раскрывает, какие ошибки может выбрасывать функция.
Мы вернемся к этим пунктам на протяжении всей главы.
Категории ошибок Link to heading
Термины «ошибка» и «сбой» могут означать множество вещей. Давайте попробуем выделить некоторые категории «вещей, которые могут пойти не так», различая их по тому, как мы обычно обрабатываем их в коде:
Ожидаемые ошибки Link to heading
Это сбои, которые программист ожидает (или должен ожидать) во время нормальной работы программы. К ним относятся такие вещи, как проблемы с сетью (сетевое соединение никогда не бывает на 100% надежным) или когда введенная пользователем строка имеет неверный формат. Мы можем дополнительно сегментировать ожидаемые ошибки по сложности причины сбоя.
→ Тривиальные ошибки — Некоторые операции имеют ровно одно ожидаемое условие сбоя. Например, когда вы ищете ключ в словаре, ключ либо присутствует (успех), либо отсутствует (сбой). В Swift мы склонны возвращать опционалы из функций, которые имеют одно четкое и часто используемое условие ошибки «не найдено» или «недопустимый ввод». Возвращение более сложного значения ошибки не дало бы вызывающему больше информации, чем то, что уже присутствует в опциональном значении. Предполагая, что причина сбоя очевидна для вызывающего, опционалы хорошо работают с точки зрения краткости (отчасти благодаря синтаксическому сахару для опционалов), безопасности (нам нужно развернуть значение, прежде чем мы сможем его использовать), документации (функции имеют опциональные типы возвращаемых значений), распространения (опциональная цепочка) и универсальности (опционалы повсеместны).
→ Богатые ошибки — Операции с сетью и файловой системой являются примерами задач, которые требуют более значительной информации об ошибках, чем «что-то пошло не так». В этих ситуациях может произойти множество различных сбоев, и программисты регулярно захотят реагировать по-разному в зависимости от типа сбоя (например, программа может захотеть повторить запрос, когда он истекает, но показать ошибку пользователю, если URL не существует). Ошибки этого типа являются основной темой этой главы. Хотя большинство стандартных библиотечных API, которые могут завершиться неудачей, возвращают тривиальные ошибки (т.е. опционалы), система Codable использует богатые ошибки. Кодирование и декодирование имеют множество различных условий ошибок, и точная информация об ошибках ценна для клиентов, чтобы выяснить, что пошло не так. Методы для кодирования и декодирования аннотированы с помощью throws, чтобы сообщить вызывающим о необходимости подготовиться к обработке ошибок.
Неожидаемые ошибки Link to heading
Это состояние, которое программист не предвидел, и которое делает продолжение работы трудным или невозможным. Обычно это означает, что предположение, сделанное программистом («это никогда не может произойти»), оказалось ложным. Примеры, когда стандартная библиотека следует этой модели, включают доступ к массиву с индексом вне границ, создание диапазона с верхней границей, меньшей, чем нижняя граница, переполнение целого числа и деление целого числа на ноль.
Обычный способ справиться с неожиданной ошибкой в Swift — позволить программе аварийно завершиться, потому что продолжение работы с неизвестным состоянием программы было бы небезопасно. Более того, эти ситуации считаются ошибками программиста, которые должны быть пойманы во время тестирования — было бы неуместно обрабатывать их, например, показывая ошибку пользователю.
В коде мы используем утверждения (т.е. assert, precondition или fatalError), чтобы проверить наши ожидания и остановить выполнение, если предположение не выполняется. Мы рассматривали эти функции в главе об опционалах. Утверждения — отличный инструмент для выявления ошибок в вашем коде. При правильном использовании они показывают вам в самый ранний возможный момент, когда ваша программа находится в состоянии, которого вы не ожидали. Они также являются полезным инструментом документации: каждый вызов assert или precondition делает предположения автора (обычно неявные) о состоянии программы видимыми для других читателей кода.
Утверждения никогда не должны использоваться для сигнализации о ожидаемых ошибках — это сделает невозможным корректную обработку этих ошибок, поскольку программы не могут восстановиться после утверждений. Противоположное — использование опционалов или функций с выбросом исключений для указания на ошибки программиста — также следует избегать, потому что лучше поймать неверное предположение на исходном уровне, чем позволить ему проникнуть через другие слои программы.
Тип Result Link to heading
Прежде чем мы более подробно рассмотрим встроенную обработку ошибок в Swift, давайте обсудим тип Result. Result был добавлен в стандартную библиотеку в Swift 5, но его варианты были популярны в сообществе Swift с самого первого релиза Swift. Понимание того, как Result используется для передачи ошибок, прояснит, как работает обработка ошибок в Swift, когда вы уберете синтаксический сахар.
Напомним из главы о перечислениях, что Result — это перечисление с формой, похожей на Optional. Как и у Optional, у Result есть два случая. У этих случаев разные названия — success и failure — но они имеют ту же функцию, что и some и none для Optional. Разница с Optional заключается в том, что Result.failure также имеет связанное значение, что позволяет экземплярам Result нести богатую информацию об ошибках:
enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
Также обратите внимание, что обобщенный параметр для полезной нагрузки случая failure ограничен протоколом Error, чтобы сообщить о его предполагаемом использовании в качестве значения ошибки.
Различия между Optional и Result должны напомнить вам о различии между тривиальными и богатыми ошибками, которое мы сделали выше. Это не случайность; мы можем использовать Result для богатых ошибок так же, как Optional используется для возврата тривиальных ошибок.
Предположим, что мы пишем функцию для чтения файла с диска. В качестве первой попытки мы могли бы определить интерфейс, используя optional. Поскольку чтение файла может завершиться неудачей, мы хотим иметь возможность вернуть nil:
func contentsOrNil(ofFile filename: String) -> String?
Подпись функции выше проста, но она не говорит нам ничего о том, почему чтение файла завершилось неудачей. Файл не существует? Или у нас нет необходимых прав? Это пример, где причина неудачи имеет значение. Давайте определим перечисление для возможных случаев ошибок:
enum FileError: Error {
case fileDoesNotExist
case noPermission
}
Теперь мы можем изменить тип нашей функции, чтобы она возвращала Result, т.е. либо строку (успех), либо FileError (неудача):
func contents(ofFile filename: String) -> Result<String, FileError>
Вызывающий функцию может переключиться по возвращаемому значению и реагировать по-разному в зависимости от конкретной ошибки, которая была возвращена. В приведенном ниже коде мы пытаемся прочитать файл и напечатать содержимое, если чтение прошло успешно. В случае неудачи мы выводим индивидуальное сообщение об ошибке для каждой возможной ошибки:
let result = contents(ofFile: "input.txt")
switch result {
case let .success(contents):
print(contents)
case let .failure(error):
switch error {
case .fileDoesNotExist:
print("Файл не найден")
case .noPermission:
print("Нет разрешения")
}
}
Обратите внимание, что ни одно из двух вложенных операторов switch не требует случая по умолчанию — компилятор может проверить, что мы переключаемся исчерпывающе по всем возможным значениям. Это работает, потому что FileError является перечислением. Если бы мы использовали Result<String, Error> в качестве возвращаемого типа функции, нам пришлось бы включить случай по умолчанию.
Исключения и обработка ошибок Link to heading
Встроенная обработка ошибок в Swift имеет много общего с подходом на основе Result, описанным в предыдущем разделе, несмотря на совершенно другую синтаксис. Вместо того чтобы возвращать тип Result из функции, чтобы указать, что она может завершиться неудачей, мы теперь помечаем её как throws. Для каждой функции, которая может выбросить ошибку, компилятор проверяет, что вызывающий код либо обрабатывает ошибку, либо передает её дальше. Преобразование функции contents(ofFile:) из предыдущего примера в синтаксис throws выглядит так:
func contents(ofFile filename: String) throws -> String
При вызове функции, которая может выбросить ошибку, наш код не скомпилируется, если мы не пометим вызов с помощью try. Ключевое слово try сигнализирует как компилятору, так и читателям кода о том, что функция может выбросить ошибку.
Вызов функции, которая может выбросить ошибку, также заставляет нас решить, как мы хотим обрабатывать ошибки. Мы можем либо обработать ошибку, используя do / catch, либо передать ошибку вверх по стеку вызовов, пометив саму вызывающую функцию как throws. Может быть более одного блока catch, и блоки catch поддерживают сопоставление с образцом для перехвата конкретных типов или значений ошибок. В приведенном ниже примере мы явно перехватываем случай FileError.fileDoesNotExist и затем обрабатываем все остальные ошибки в блоке catch-all. Внутри блока catch-all компилятор автоматически делает доступной переменную с именем error (похоже на неявную переменную newValue в обработчике willSet свойства):
do {
let result = try contents(ofFile: "input.txt")
print(result)
} catch FileError.fileDoesNotExist {
print("Файл не найден")
} catch {
print(error)
// Обработка любой другой ошибки.
}
Синтаксис обработки ошибок, вероятно, знаком вам. Многие другие языки используют те же ключевые слова try, catch и throw для обработки исключений. Несмотря на сходство, обработка ошибок в Swift не влечет за собой дополнительных затрат времени выполнения, которые часто ассоциируются с исключениями. Компилятор обрабатывает throw как обычный возврат, что делает оба пути выполнения очень быстрыми.
Если мы хотим предоставить больше информации в наших ошибках, мы можем использовать перечисление с ассоциированными значениями. Например, библиотека для разбора файлов может моделировать свои условия ошибок следующим образом:
enum ParseError: Error {
case wrongEncoding
case warning(line: Int, message: String)
}
Перечисление является популярным выбором для моделирования значений ошибок, но обратите внимание, что мы также могли бы использовать структуру или класс; любой тип, который соответствует протоколу Error, может быть использован в качестве ошибки в функции, которая может выбросить ошибку. И поскольку протокол Error не имеет требований, любой тип может выбрать соответствие ему без дополнительной реализации.
Для быстрых тестов или прототипов иногда бывает полезно сделать String соответствующим Error, что можно сделать с помощью этой однострочной записи: extension String: Error {}. Это позволяет нам обрабатывать любое сообщение об ошибке напрямую как значение ошибки, например, в throw "Файл не найден". Это не то, что мы бы рекомендовали для производственного кода, не в последнюю очередь потому, что соответствие типа, который вы не владеете, протоколу, которому вы не владеете, не является целесообразным (см. главу о протоколах для получения дополнительной информации). Но это неплохой трюк для сессии REPL или аналогичных сред.
Тип нашей функции разбора выглядит так:
func parse(text: String) throws -> [String]
Теперь, если мы хотим разобрать строку, мы снова можем использовать сопоставление с образцом, чтобы различать случаи ошибок. В случае предупреждения мы можем связать номер строки и сообщение предупреждения с переменными, как мы бы сделали в операторе switch:
do {
let result = try parse(text: "{ \"message\": \"Мы пришли с миром\" }")
print(result)
} catch ParseError.wrongEncoding {
print("Неверная кодировка")
} catch let ParseError.warning(line, message) {
print("Предупреждение на строке \(line): \(message)")
} catch {
preconditionFailure("Неожиданная ошибка: \(error)")
}
Если вы прищуритесь и посмотрите на общую структуру этого кода, с его отдельными секциями для успешных и неудачных путей и использованием сопоставления с образцом для связывания значений, он выглядит удивительно похоже на переключение по значению Result. Параллели не случайны — обработка ошибок в Swift по сути является более удобным синтаксисом для создания и распаковки значений, похожих на Result.
Типизированные и нетипизированные ошибки Link to heading
Что-то в коде do / catch в предыдущем разделе кажется не совсем правильным. Даже если мы абсолютно уверены, что единственной ошибкой, которая может произойти, является ParseError (которую мы обрабатываем исчерпывающе), нам все равно нужно написать финальный блок catch, чтобы убедить компилятор, что мы поймали все возможные ошибки. Это связано с тем, что встроенная обработка ошибок в Swift использует нетипизированные ошибки — мы можем только пометить функцию как throws, но не можем указать, какие ошибки она может выбросить. В результате компилятор всегда требует наличия блока catch all, чтобы доказать, что все ошибки обработаны исчерпывающе. Сделать обработку ошибок нетипизированной было преднамеренным решением команды Swift Core. Обоснование заключается в том, что исчерпывающая обработка ошибок непрактична и нежелательна в большинстве ситуаций; обычно вам, вероятно, интересны только одна или две конкретные ошибки (если вообще) и вы готовы обрабатывать все остальные ошибки в блоке catch all.
С другой стороны, тип Result использует типизированные ошибки: Result имеет два обобщенных параметра, Success и Failure, и последний указывает конкретный тип значения ошибки. Ранее эта функция позволила нам исчерпывающе переключаться по Result<String, FileError>; в качестве другого примера вот вариант функции parse(text:), где мы заменили аннотацию throws на тип возвращаемого значения Result<[String], ParseError>. Благодаря конкретному типу ошибки эта функция также позволяет исчерпывающе переключаться по ее случаям неудачи:
func parse(text: String) -> Result<[String], ParseError>
Почему команда Swift Core решила принять это несоответствие между использованием нетипизированных ошибок для встроенной обработки ошибок и типом Result с типизированными ошибками? В конце концов, команда Core также могла бы выбрать вариант Result с нетипизированным случаем неудачи, т.е. таким, где случай неудачи мог бы быть любым значением типа Error. Оказывается, что тип Result, который у нас есть, на самом деле является гибридом, который поддерживает оба паттерна. Если вы не хотите конкретный тип ошибки, вы можете указать Result<…, Error>, чтобы принимать любое значение ошибки.
Таким образом, Result действительно дает нам выбор между типизированными и нетипизированными ошибками. Компромисс заключается в том, что Result с нетипизированным типом неудачи требует немного больше написания, чем было бы необходимо в противном случае, потому что нам нужно будет передавать параметр Error. Если это вас беспокоит, вы всегда можете создать типовой псевдоним для типа Result с нетипизированными ошибками:
typealias UResult<Success> = Result<Success, Error>
Кстати, тот факт, что мы можем написать Result<…, Error>, использует некоторые специальные магические возможности компилятора для протокола Error. Мы видели, что параметр Failure ограничен типом Error:
enum Result<Success, Failure: Error>
Поскольку протоколы Swift обычно не соответствуют сами себе, переменная типа Result<…, Error> не удовлетворяет ограничению Failure: Error. Чтобы позволить использовать Result с нетипизированными ошибками таким образом, команда Swift добавила специальный случай в компилятор, который позволяет самосоответствие для Error (но для других протоколов — нет). Мы обсудим это более подробно в главе о протоколах.
Теперь, когда у нас есть тип Result с поддержкой конкретных типов ошибок, вполне возможно, что эта функция также будет добавлена в модель встроенной обработки ошибок в будущей версии Swift. До тех пор использование Result с типизированными ошибками является хорошим выбором для кода, где вы хотите, чтобы компилятор проверял, что вы поймали все возможные ошибки. Если и когда мы получим типизированные ошибки повсюду, возможность указать конкретный тип ошибки, которую функция может выбросить, почти наверняка станет необязательной функцией, а не обязательной. Это связано с тем, что типизированные ошибки имеют значительные недостатки:
→ Конкретные типы ошибок делают составление функций с выбросом и агрегацию ошибок гораздо более сложными. Любая функция, которая вызывает несколько других функций с выбросом, либо должна будет передавать несколько типов ошибок вверх по стеку вызовов, либо придумывать новый пользовательский тип ошибки для агрегации ошибок из нижних уровней. Это быстро выйдет из-под контроля. Мы вернемся к этому позже в разделе о цепочках ошибок.
→ Строго типизированные ошибки делают библиотеки менее расширяемыми. Например, каждый раз, когда функция добавляет новое условие ошибки, это будет изменением, разрушающим совместимость, для всех клиентов, которые исчерпывающе обрабатывают ошибки. Чтобы поддерживать бинарную совместимость с различными версиями библиотек, клиентам придется добавлять блоки default ко всем операторам do / catch, как это необходимо для не замороженных перечислений. Эта проблема сама по себе является достаточной причиной, по которой такие фреймворки, как Cocoa, вероятно, никогда не будут иметь типизированные ошибки.
→ В отличие от исчерпывающего переключения по перечислениям, исчерпывающее перехватывание всех возможных условий ошибки обычно не является ни необходимым, ни практичным. Подумайте о том, сколько разных вещей может пойти не так, когда вы выполняете сетевой запрос — программисту почти невозможно придумать значимую реакцию на каждую возможную проблему. Большинство программ, вероятно, будут обрабатывать несколько ошибок явно и иметь общий обработчик ошибок, который может просто записывать ошибку или представлять ее пользователю для остальных.
Поскольку ошибки нетипизированные, важно документировать типы ошибок, которые могут выбрасывать ваши функции. Xcode поддерживает ключевое слово Throws в разметке документации для этой цели. Вот пример:
/// Открывает текстовый файл и возвращает его содержимое.
///
/// - Параметр filename: Имя файла для чтения.
/// - Возвращает: Содержимое файла, интерпретируемое как UTF-8.
/// - Выбрасывает: `FileError`, если файл не существует или
/// процесс не имеет прав на чтение.
func contents(ofFile filename: String) throws -> String
Всплывающее окно Quick Help, которое появляется, когда вы нажимаете Option и кликаете по имени функции, теперь будет включать дополнительный раздел для выбрасываемых ошибок.
Неигнорируемые ошибки Link to heading
В введении к этой главе мы определили безопасность как один из факторов хорошей системы обработки ошибок. Большим преимуществом использования встроенной обработки ошибок является то, что компилятор гарантирует, что вы не сможете игнорировать случай ошибки при вызове функции, которая может выбросить исключение. С Result это не всегда так.
Например, рассмотрим методы Foundation, такие как Data.write(to:options:) (для записи байтов в файл) или FileManager.removeItem(at:) (для удаления файла):
extension Data {
func write(to url: URL, options: Data.WritingOptions = []) throws
}
extension FileManager {
func removeItem(at URL: URL) throws
}
Если бы эти методы использовали обработку ошибок на основе Result, их объявления выглядели бы так:
extension Data {
func write(to url: URL, options: Data.WritingOptions = []) -> Result<(), Error>
}
extension FileManager {
func removeItem(at URL: URL) -> Result<(), Error>
}
Особенность этих методов заключается в том, что мы вызываем их ради побочных эффектов, а не ради их возвращаемых значений — на самом деле, ни один из методов не имеет значимого возвращаемого значения, кроме “операция выполнена или не выполнена”. С вариантами на основе Result программистам слишком легко (случайно или намеренно) игнорировать любые сбои, написав код вроде этого:
_ = FileManager.default.removeItem(at: url)
При вызове вариантов на основе throws, с другой стороны, компилятор заставляет нас предварять вызов try. Компилятор также требует, чтобы мы либо обернули этот вызов в блок do/catch, либо передали ошибку вверх по стеку вызовов. Это сразу же делает ясным для программиста и других читателей кода, что операции могут завершиться неудачей, и компилятор заставит нас обработать ошибку.
Хотя Result<(), Error> может не быть хорошим выбором для типа возвращаемого значения функции, он часто используется для отчетности об ошибках на основе обратных вызовов (где throws недоступен, как мы увидим в разделе “Ошибки и обратные вызовы”), когда случай успеха не имеет значимого полезного значения. Пустой кортеж, или Void, — это тип, который имеет ровно одно возможное значение, (), (что сбивает с толку, так как одно и то же написание используется для типа и его единственного значения). Следовательно, случай успеха не несет дополнительной информации, кроме “операция выполнена успешно”.
Преобразования ошибок Link to heading
Преобразование между выбросами и опциональными значениями Link to heading
Ошибки и опциональные значения — это распространенные способы, с помощью которых функции сигнализируют о том, что что-то пошло не так. В введении к этой главе мы дали вам несколько советов о том, как решить, какой шаблон использовать для ваших собственных функций. Вам предстоит много работать как с ошибками, так и с опциональными значениями, и передача результатов в другие API часто делает необходимым преобразование между выбрасывающими функциями и опциональными значениями.
Ключевое слово try? позволяет нам игнорировать ошибку выбрасывающей функции и преобразовывать возвращаемое значение в опциональное; опциональное значение сообщает нам, удалась ли функция или нет:
if let result = try? parse(text: input) {
print(result)
}
Использование try? означает, что мы получаем меньше информации, чем раньше: мы знаем только, вернула ли функция успешное значение или возникла ошибка — конкретная информация об этой ошибке отбрасывается. Чтобы преобразовать опциональное значение в функцию, которая выбрасывает ошибку, нам нужно предоставить значение ошибки, которое должно использоваться в случае, если опциональное значение равно nil. Вот расширение для Optional, которое распаковывает само себя и выбрасывает заданную ошибку, если находит nil:
extension Optional {
/// Распаковывает `self`, если оно не `nil`.
/// Выбрасывает заданную ошибку, если `self` равно `nil`.
func or(error: Error) throws -> Wrapped {
switch self {
case let x?:
return x
case nil:
throw error
}
}
}
А вот пример использования:
do {
let int = try Int("42").or(error: ReadIntError.couldNotRead)
} catch {
print(error)
}
Это может быть полезно в сочетании с несколькими операциями try, или когда вы работаете внутри функции, которая уже помечена как throws. Это также может быть полезным шаблоном в юнит-тестах — настолько, что фреймворк XCTest предоставляет удобный API для этого. XCTest может автоматически завершить тест с ошибкой, если вы выбрасываете ошибку внутри метода теста (который вы должны пометить как throws, чтобы он компилировался). Если ваш тест зависит от того, что опциональное значение не равно nil, вы можете вызвать let nonOptional = try XCTUnwrap(someOptional), чтобы распаковать опциональное значение или завершить тест с ошибкой, если опциональное значение равно nil, в одной строке.
Существование ключевого слова try? может показаться противоречащим философии Swift, согласно которой игнорирование ошибок не должно быть допустимо. Тем не менее, вам все равно нужно явно писать try?, чтобы компилятор заставил вас признать свои действия и сделать их явными для других читателей кода. try? является законным вариантом, когда вас не интересует сообщение об ошибке.
Существует третья вариация try: try!. Это используется, когда вы знаете, что не может быть результата с ошибкой. Точно так же, как принудительное распаковка опционального значения, которое равно nil, try! вызывает сбой, если ваши предположения оказываются неверными во время выполнения.
Преобразование между throws и Result Link to heading
Мы увидели, что Result и обработка ошибок с использованием throws — это фактически две стороны одной медали. Учитывая различия в обработке типизированных и нетипизированных ошибок, вы можете рассматривать значение Result как конкретный результат (т.е. значение, которое вы можете сохранить или передать) функции, которая может выбросить ошибку. Учитывая эту двойственность, неудивительно, что стандартная библиотека предоставляет способы преобразования между этими двумя представлениями.
Чтобы вызвать функцию, которая может выбросить ошибку, и обернуть ее результат в Result, используйте инициализатор init(catching:), который принимает функцию, выбрасывающую ошибку, и преобразует ее в Result. Реализация выглядит следующим образом:
extension Result where Failure == Swift.Error {
/// Создает новый результат, оценивая выбрасывающую замыкание, захватывая
/// возвращаемое значение как успех или любую выброшенную ошибку как неудачу.
init(catching body: () throws -> Success) {
do {
self = .success(try body())
} catch {
self = .failure(error)
}
}
}
Вот пример:
let encoder = JSONEncoder()
let encodingResult = Result { try encoder.encode([1, 2]) } // success(5 bytes)
type(of: encodingResult) // Result<Data, Error>
Это полезно, если вы хотите отложить обработку ошибки на более поздний срок или передать результат другой функции.
Обратная операция называется Result.get(). Она оценивает (т.е. переключается) Result и обрабатывает случай неудачи как ошибку, которую нужно выбросить. Вот реализация:
extension Result {
public func get() throws -> Success {
switch self {
case let .success(success):
return success
case let .failure(failure):
throw failure
}
}
}
Цепочка ошибок Link to heading
Часто вызывают несколько функций, которые могут завершиться неудачей, последовательно. Например, операция может быть разделена на несколько подзадач, где результат одной подзадачи становится входными данными для следующей. Каждая отдельная подзадача может завершиться с ошибкой, поэтому, если возникает ошибка, вся операция должна немедленно прерваться.
Chaining throws Link to heading
Не все системы обработки ошибок хорошо справляются с вышеописанным случаем, но встроенная обработка ошибок в Swift выделяется в этом отношении. Нет необходимости в вложенных операторах if или подобных конструкциях для извлечения возвращаемых значений перед передачей их следующей функции; мы просто помещаем все вызовы функций в один блок do / catch (или оборачиваем их в функцию, которая может выбрасывать ошибки). Первая ошибка, которая возникает, разрывает цепочку и передает управление в блок catch (или пропагирует ошибку вызывающему коду).
Вот пример операции с тремя подзадачами:
func complexOperation( filename: String) throws -> [String] {
let text = try contents(ofFile: filename)
let segments = try parse(text: text)
return try process(segments: segments)
}
# **ChainingResult**
Давайте сравним чистый пример на основе `try` с эквивалентным кодом, использующим тип `Result`. Связывание нескольких функций, которые возвращают `Result`, требует много работы, если мы делаем это вручную; сначала мы вызываем первую функцию и переключаемся на ее возвращаемое значение, и если это `.success`, мы можем передать распакованное значение второй функции и начать заново. Как только одна функция возвращает `.failure`, цепочка прерывается, и мы сразу возвращаем ошибку вызывающему:
```swift
func complexOperation1(filename: String) -> Result<[String], Error> {
let result1 = contents(ofFile: filename)
switch result1 {
case .success(let text):
let result2 = parse(text: text)
switch result2 {
case .success(let segments):
return process(segments: segments)
.mapError { $0 as Error }
case .failure(let error):
return .failure(error as Error)
}
case .failure(let error):
return .failure(error as Error)
}
}
Это быстро становится неаккуратным, потому что каждая дополнительная функция в цепочке требует еще одного вложенного оператора switch. Обратите внимание также, что нам пришлось дублировать один и тот же путь ошибки в каждом switch.
Прежде чем мы попытаемся рефакторить этот код, давайте еще раз взглянем на то, как мы обрабатываем случаи ошибок в приведенном выше коде. Это сигнатуры функций, которые представляют три подзадачи в нашем примере:
func contents(ofFile filename: String) -> Result<String, FileError>
func parse(text: String) -> Result<[String], ParseError>
func process(segments: [String]) -> Result<[String], ProcessError>
Каждая из этих функций имеет разный тип ошибки: FileError, ParseError и ProcessError. По мере того как мы проходим через цепочку подзадач, нам не только нужно заботиться о преобразовании типов успеха (от String к [String] и снова к [String]); нам также нужно позаботиться о преобразовании типов ошибок в агрегатный тип (который в этом примере является Error, но может быть другим конкретным типом). Мы можем увидеть преобразования ошибок в трех местах в коде:
- Две строки
return .failure(error as Error)конвертируют значение ошибки из его конкретного типа вError. Мы могли бы опустить частьas Error— компилятор добавил бы это неявно, но добавление этого иллюстрирует, что на самом деле происходит. - Для последнего шага цепочки мы не можем просто написать
return process(segments: segments), потому что возвращаемый типprocess(segments)несовместим с требуемым возвращаемым типомResult<[String], Error>— нам нужно использовать методmapError(который является частью типаResult), чтобы снова преобразовать тип ошибки.
Несмотря на сложность, введенную строгими типами ошибок, мы должны рефакторить беспорядок вложенных операторов switch. К счастью, Result включает функциональность для этого. Шаблон, который мы использовали несколько раз в приведенном выше коде — переключение по Result и либо вызов следующего шага в цепочке с распакованным значением успеха, либо прерывание, когда мы сталкиваемся с ошибкой — именно то, что делает метод flatMap типа Result.
Его структура идентична методу flatMap для опционалов, который мы рассмотрели в главе об опционалах. Замена операторов switch на flatMap значительно упрощает код. На самом деле, конечный результат довольно элегантен, если не почти так же чист, как пример на основе throws:
func complexOperation2(filename: String) -> Result<[String], Error> {
return contents(ofFile: filename).mapError { $0 as Error }
.flatMap { text in
parse(text: text).mapError { $0 as Error }
}
.flatMap { segments in
process(segments: segments).mapError { $0 as Error }
}
}
Обратите внимание, что нам все еще нужно иметь дело с несовместимыми типами ошибок. Методы map и flatMap типа Result только преобразуют случай успеха и оставляют тип случая ошибки неизменным. И связывание нескольких операций map или flatMap работает только в том случае, если все вовлеченные типы Result имеют один и тот же тип ошибки. Мы достигаем этого в нашем примере с несколькими вызовами mapError; их задача — преобразовать конкретные ошибки в Error.
Пример, который мы обсуждали в этом разделе, хорошо иллюстрирует, что строго типизированные ошибки могут быть проблематичными и часто могут вызвать больше проблем, чем стоят. Код на основе flatMap определенно был бы более читаемым без вызовов mapError. Более того, агрегирующая функция в конечном итоге стирает конкретные типы ошибок, так что фактический код обработки ошибок выше в стеке вызовов даже не может воспользоваться типами.
Ошибки и обратные вызовы Link to heading
Встроенная обработка ошибок Swift бесшовно интегрируется с асинхронными API, которые используют новую модель async/await: все, что мы обсуждали до сих пор, в равной степени применимо к асинхронным функциям или методам. Однако встроенная обработка ошибок не работает хорошо для асинхронных API на основе обратных вызовов. Давайте рассмотрим функцию, которая асинхронно вычисляет большое число и вызывает наш код, когда вычисление завершено:
func compute(callback: (Int) -> ())
Мы можем вызвать эту функцию, предоставив функцию обратного вызова. Обратный вызов получает результат в качестве единственного параметра:
compute { number in
print(number)
}
Как мы можем интегрировать ошибки в этот дизайн? Если опционал предоставляет достаточную информацию об ошибке (т.е. существует только одно простое условие ошибки), мы могли бы указать, что обратный вызов получает опциональное целое число, которое будет равно nil в случае неудачи:
func computeOptional(callback: (Int?) -> ())
Теперь в нашем обратном вызове мы должны развернуть опционал, например, используя оператор ??:
computeOptional { numberOrNil in
print(numberOrNil ?? -1)
}
Что если мы хотим сообщить более конкретные ошибки в обратный вызов? Эта сигнатура функции кажется естественным решением:
func computeThrows(callback: (Int) throws -> ())
Но это не делает то, что мы хотим; этот тип имеет совершенно другое значение. Вместо того чтобы сказать, что вычисление может завершиться неудачей, он выражает, что сам обратный вызов может выбросить ошибку. Проблема становится более ясной, когда мы пытаемся переписать эту неправильную попытку в версию, которая использует Result:
func computeResult(callback: (Int) -> Result<(), Error>)
Это тоже неверно — нам нужно обернуть аргумент Int в Result, а не в возвращаемый тип обратного вызова. В конце концов, вот правильное решение:
func computeResult(callback: (Result<Int, Error>) -> ())
Несовместимость встроенной обработки ошибок с API на основе обратных вызовов иллюстрирует ключевую разницу между throws и Optional / Result: только последние являются значениями, которые мы можем свободно передавать, в то время как throws не так гибок. Нам нравится, как это сказал Джошуа Эммонс:
Видите ли, throw, как return, работает только в одном направлении; вверх. Мы можем выбросить ошибку “вверх” вызывающему, но не можем выбросить ошибку “вниз” в качестве параметра другой функции, которую мы вызываем.
Способность передавать ошибку “вниз” в функцию продолжения — это именно то, что нам нужно в контекстах асинхронных API на основе обратных вызовов. К сожалению, нет четкого способа написать вариант выше с throws. Все, что мы можем сделать, это обернуть Int внутри другой функции, которая выбрасывает ошибку. Это усложняет сигнатуру:
func compute(callback: (() throws -> Int) -> ())
И использование этого варианта становится более сложным для вызывающего. Чтобы получить целое число, обратный вызов теперь должен вызвать функцию, выбрасывающую ошибку. Здесь вызывающему необходимо выполнить проверку ошибок:
compute { (resultFunc: () throws -> Int) in
do {
let result = try resultFunc()
print(result)
} catch {
print("Произошла ошибка: \(error)")
}
}
Это работает, но это определенно не идиоматичный Swift; Result — это путь для обработки ошибок с обратными вызовами. И начиная с Swift 5.5, функцию compute, безусловно, следует написать как асинхронную функцию, что значительно упрощает обработку ошибок:
func compute() async throws -> Int
do {
print(try await compute())
} catch {
print("Произошла ошибка: \(error)")
}
Очистка с использованием defer Link to heading
Во многих языках программирования есть конструкция try / finally, где блок, помеченный finally, всегда выполняется, когда функция возвращается, независимо от того, было ли выброшено исключение или нет. Ключевое слово defer в Swift имеет аналогичное назначение, но работает немного иначе. Как и finally, блок defer всегда выполняется при выходе из области видимости, независимо от причины выхода — будь то успешное возвращение значения, возникновение ошибки или любая другая причина. Это делает defer хорошим вариантом для выполнения необходимой работы по очистке. В отличие от finally, блок defer не требует наличия ведущего блока try или do, и он более гибок в том, где вы его размещаете в своем коде.
Давайте вернемся к функции contents(ofFile:) из начала этой главы и рассмотрим возможную реализацию, использующую defer:
func contents(ofFile filename: String) throws -> String {
let file = open(filename, O_RDONLY)
defer { close(file) }
return try load(file: file)
}
Блок defer на второй строке гарантирует, что файл будет закрыт, когда функция вернется, независимо от того, завершится ли она успешно или выбросит ошибку. Хотя defer часто используется вместе с обработкой ошибок, он может быть полезен и в других контекстах — например, когда вы хотите держать код для инициализации и очистки ресурса (например, открытия и закрытия файла) близко друг к другу. Размещение связанных строк рядом друг с другом может значительно повысить читаемость вашего кода, особенно в более длинных функциях.
Если в одной области видимости есть несколько операторов defer, они выполняются в обратном порядке; вы можете думать о них как о стеке. Сначала это может показаться странным, что блоки defer выполняются в обратном порядке. Однако это быстро становится понятным, если мы посмотрим на этот пример выполнения запроса к базе данных:
let database = try openDatabase(...)
defer { closeDatabase(database) }
let connection = try openConnection(database)
defer { closeConnection(connection) }
let result = try runQuery(connection, ...)
Этот код должен сначала открыть базу данных и соединение с базой данных, прежде чем он сможет, наконец, выполнить запрос. Если возникает ошибка — например, во время вызова runQuery — очистка ресурсов должна происходить в обратном порядке; мы хотим сначала закрыть соединение, а затем базу данных. Поскольку операторы defer выполняются в обратном порядке, это происходит автоматически.
Блок defer выполняется непосредственно перед тем, как управление программой передается за пределы области видимости, в которой появляется оператор defer. Даже значение оператора return вычисляется до того, как какие-либо блоки defer в той же области видимости будут выполнены. Вы можете воспользоваться этим поведением, чтобы изменить переменную после возврата ее предыдущего значения вызывающему коду. В следующем примере функция increment использует defer для увеличения значения захваченной переменной counter после возврата ее предыдущего значения:
var counter = 0
func increment() -> Int {
defer { counter += 1 }
return counter
}
increment() // 0
counter // 1
Если вы просмотрите исходный код стандартной библиотеки, вы увидите этот шаблон время от времени. Написание той же логики без defer потребовало бы объявления локальной переменной для временного хранения значения counter.
Существуют некоторые ситуации, в которых операторы defer не выполняются: когда ваша программа вызывает сегментацию памяти (segfault) или когда она вызывает фатальную ошибку (например, используя fatalError или принудительно разыменовывая nil), все выполнение останавливается немедленно.
Повторное выбрасывание исключений Link to heading
Существование функций, которые могут выбрасывать исключения, создает проблему для функций, принимающих другие функции в качестве аргументов, таких как map или filter. В главе о встроенных коллекциях мы обсуждали тип гипотетического метода filter для массива (реальный filter определен в Sequence и немного сложнее):
func filter( _ isIncluded: (Element) -> Bool) -> [Element]
Это определение работает, но у него есть один недостаток: компилятор не примет ни одну функцию, выбрасывающую исключения, в качестве предиката, потому что параметр isIncluded не помечен как throws.
Давайте рассмотрим пример, где это ограничение становится проблемой. Начнем с написания функции, которая проверяет файл на наличие некоторой формы валидности (факторы, которые функция использует для определения, является ли файл валидным или нет, не важны для примера). Функция checkFile либо возвращает логическое значение (true для валидного, false для невалидного), либо выбрасывает ошибку, если что-то пошло не так при проверке файла:
func checkFile( filename: String) throws -> Bool
Предположим, у нас есть массив имен файлов, и мы хотим отфильтровать невалидные файлы. Естественно, мы хотели бы использовать filter для этого, но компилятор не позволит это сделать, потому что checkFile — это функция, выбрасывающая исключения:
let filenames: [String] = ...
// Ошибка: Вызов может выбросить исключение, но не помечен как 'try'.
let validFiles = filenames.filter(checkFile)
Мы могли бы обойти эту проблему, обрабатывая ошибку локально внутри предиката filter:
let validFiles = filenames.filter { filename in
do {
return try checkFile(filename: filename)
} catch {
return false
}
}
Но это неудобно, и это может даже не быть тем, что мы хотим — приведенный выше код тихо игнорирует ошибки, перехватывая их и возвращая false, но что если мы хотим прервать всю операцию, когда возникает ошибка?
Одно из решений заключалось бы в том, чтобы стандартная библиотека аннотировала предикатную функцию с помощью throws в объявлении filter:
func filter( _ isIncluded: (Element) throws -> Bool) throws -> [Element]
Это сработало бы, но это было бы столь же неудобно, потому что теперь каждый вызов filter становится вызовом, выбрасывающим исключение, который требует аннотации try (или try!). Делать это для каждой функции высшего порядка в стандартной библиотеке привело бы к коду, покрытому ключевыми словами try, тем самым подрывая основную цель try, которая заключается в том, чтобы позволить читателям кода быстро различать вызывающие и невызывающие исключения вызовы.
Другой альтернативой является определение двух версий filter: одна, которая выбрасывает, и одна, которая не выбрасывает. За исключением аннотации try, их реализации были бы идентичны. Мы могли бы полагаться на компилятор, чтобы он выбирал наилучшее перегруженное определение для каждого вызова. Это лучше, потому что это сохраняет чистоту мест вызова, но это все равно довольно расточительно.
К счастью, Swift имеет лучшее решение в виде ключевого слова rethrows. Аннотирование функции с помощью rethrows сообщает компилятору, что эта функция будет выбрасывать ошибку только тогда, когда ее параметр-функция выбрасывает ошибку. Таким образом, истинная сигнатура метода для filter выглядит так:
func filter( _ isIncluded: (Element) throws -> Bool) **rethrows** -> [Element]
Предикатная функция по-прежнему помечена как throws, что указывает на то, что вызывающие функции могут передавать выбрасывающую функцию. В своей реализации filter должен вызывать предикат с помощью try. Аннотация rethrows гарантирует, что filter будет передавать ошибки, выбрасываемые предикатной функцией, вверх по стеку вызовов, но filter никогда не выбросит ошибку самостоятельно. Это позволяет компилятору отказаться от требования, чтобы filter вызывался с try, когда вызывающий передает невызывающую предикатную функцию.
Почти все функции последовательностей и коллекций в стандартной библиотеке, которые принимают функцию в качестве аргумента, аннотированы с помощью rethrows, с одним важным исключением: методы ленивых коллекций, которые мы обсуждали в главе о протоколах коллекций, обычно не поддерживают выбрасывание, потому что ленивые коллекции хранят функцию преобразования для последующей оценки. Поддержка выбрасывания в этом контексте потребовала бы аннотации try на любом вызове API коллекции, который мог бы перейти к ленивой коллекции, еще раз подрывая цель try как маркер для фактических вызывающих исключения вызовов.
Преобразование ошибок в Objective-C Link to heading
Objective-C имеет механизм, аналогичный throws и try. (В Objective-C действительно есть обработка исключений, использующая те же ключевые слова, но исключения в Objective-C должны использоваться только для сигнализации об ошибках программиста. Вы редко поймаете исключение Objective-C в обычном приложении.) Вместо этого распространенный шаблон в Cocoa заключается в том, что метод возвращает NO или nil, когда происходит ошибка. Методы, которые могут завершиться неудачей, также принимают ссылку на указатель NSError в качестве дополнительного аргумента; они могут использовать этот указатель, чтобы передать конкретную информацию об ошибке обратно вызывающему коду. Например, метод contents(ofFile:) будет выглядеть так в Objective-C:
-(NSString*)contentsOfFile:(NSString*)filename error:(NSError**)error;
Swift автоматически переводит методы, которые следуют этому шаблону, в синтаксис throws. Параметр error удаляется, так как он больше не нужен, а типы возвращаемых значений BOOL изменяются на Void. Метод выше импортируется следующим образом:
func contents(ofFile filename: String) throws -> String
Автоматическое преобразование работает для всех методов Objective-C, которые используют эту структуру. Другие параметры NSError — например, в асинхронных API, которые передают ошибку обратно вызывающему коду в блоке завершения — преобразуются в протокол Error, поэтому вам обычно не нужно взаимодействовать с NSError напрямую.
Если вы передаете ошибку Swift в метод Objective-C, она будет преобразована обратно в NSError. Поскольку все объекты NSError должны иметь строку домена и целочисленный код ошибки, во время выполнения будут сгенерированы значения по умолчанию, если это необходимо, используя квалифицированное имя типа в качестве домена и нумерование случаев перечисления с нуля для кода ошибки. При желании вы можете предоставить свои собственные значения, приведя свой тип к протоколу CustomNSError.
Например, мы можем расширить наш ParseError следующим образом:
extension ParseError: CustomNSError {
static let errorDomain = "io.objc.parseError"
var errorCode: Int {
switch self {
case .wrongEncoding: return 100
case .warning(_, _): return 200
}
}
var errorUserInfo: [String: Any] {
return [:]
}
}
Аналогичным образом вы можете добавить соответствие одному или обоим из следующих протоколов, чтобы обеспечить лучшую совместимость с конвенциями Cocoa:
→ LocalizedError — Это предоставляет локализованные сообщения, описывающие ошибку (errorDescription), почему произошла ошибка (failureReason), советы по восстановлению (recoverySuggestion) и дополнительный текст помощи (helpAnchor).
→ RecoverableError — Это описывает ошибку, от которой пользователь может восстановиться, предоставляя один или несколько вариантов восстановления и выполняя восстановление, когда пользователь этого запрашивает. Это в основном используется в приложениях macOS с использованием AppKit.
Даже без соответствия LocalizedError, каждый тип, который соответствует Error, имеет свойство localizedDescription, которое вы можете переопределить в своих собственных типах. Однако, поскольку localizedDescription не является обязательным требованием протокола Error, это свойство не динамически передается. Если вы также не соответствуете LocalizedError, ваше пользовательское localizedDescription не будет использоваться API Objective-C или значениями, обернутыми в Error existential. При написании приложений Cocoa вы всегда должны реализовывать протокол LocalizedError для типов ошибок, которые передаются в API Cocoa. Для получения дополнительной информации о динамической передаче и existential, обратитесь к главе о протоколах.
Резюме Link to heading
Swift предоставляет нам множество вариантов для обработки неожиданных ситуаций в нашем коде. Когда мы не можем продолжать выполнение, мы можем использовать fatalError или утверждение (assertion). Когда нас не интересует тип ошибки или если существует только один тип ошибки, мы можем использовать опционалы. Когда нам нужно больше одного типа ошибки или мы хотим предоставить дополнительную информацию, мы можем использовать встроенную модель обработки ошибок Swift или тип Result.
Когда Apple представила модель обработки ошибок в Swift 2.0, многие в сообществе были скептически настроены. Факт, что throws использует не типизированные ошибки, воспринимался как ненужное отклонение от строгой типизации в других частях языка. Мы тоже были скептически настроены, но, оглядываясь назад, мы считаем, что команда Swift оказалась права, потому что детальная обработка ошибок часто оказывается ненужной. Теперь, когда у нас есть тип Result с обобщенным типом ошибки, есть большая вероятность, что строго типизированная обработка ошибок будет добавлена как опциональная функция в будущем.
Обработка ошибок является хорошим примером того, как Swift является прагматичным языком, который в первую очередь оптимизирует для самых распространенных случаев использования. Сохранение синтаксиса, знакомого разработчикам, привыкшим к языкам в стиле C, является более важной целью, чем соблюдение “более чистого” функционального стиля, основанного на Result и flatMap — хотя они теперь также доступны в стандартной библиотеке. Дизайн модели обработки ошибок следует общей теме для Swift: цель состоит в том, чтобы обернуть безопасные, “функциональные” концепции в дружелюбный, императивный синтаксис (другим примером является модель изменяемости для типов значений). Введение модели конкурентности в стиле async/await, включая поддержку обработки ошибок в стиле throws в асинхронных функциях, является дополнительным шагом в этом направлении.
Кодирование и Декодирование Link to heading
14 Link to heading
Сериализация внутренних структур данных программы в какой-либо формат обмена данными и наоборот — одна из самых распространенных задач программирования. Swift называет эти операции кодированием и декодированием.
Система Codable (названная в честь своего базового «протокола», который на самом деле является типом-алиасом) представляет собой стандартизированный дизайн для кодирования и декодирования данных, который могут использовать все пользовательские типы. Она разработана вокруг трех центральных целей:
→ Универсальность — Она должна работать со структурами, перечислениями и классами.
→ Безопасность типов — Форматы обмена данными, такие как JSON, часто слабо типизированы, в то время как ваш код должен работать с сильно типизированными структурами данных.
→ Снижение шаблонного кода — Разработчики должны писать как можно меньше повторяющегося «адаптерного кода», чтобы позволить пользовательским типам участвовать в системе. Компилятор должен генерировать этот код автоматически.
Типы объявляют свою способность быть (де)сериализованными, соответствуя протоколам Encodable и/или Decodable. Каждый из этих протоколов имеет всего одно требование — Encodable определяет метод encode(to:), в котором значение кодирует само себя, а Decodable специфицирует инициализатор для создания экземпляра из сериализованных данных:
/// Тип, который может кодировать себя в внешнее представление.
public protocol Encodable {
/// Кодирует это значение в данный кодировщик.
public func encode(to encoder: Encoder) throws
}
/// Тип, который может декодировать себя из внешнего представления.
public protocol Decodable {
/// Создает новый экземпляр, декодируя из данного декодировщика.
public init(from decoder: Decoder) throws
}
Поскольку большинство типов, которые принимают один из протоколов, также примут другой, стандартная библиотека предоставляет тип-алиас Codable как сокращение для обоих:
public typealias Codable = Decodable & Encodable
Все основные типы стандартной библиотеки — включая Bool, числовые типы и String — являются кодируемыми «из коробки», как и опционалы, массивы, словари, множества и диапазоны, содержащие кодируемые элементы. Более того, многие распространенные типы данных, используемые в фреймворках Apple — включая Data, Date, URL, CGPoint и CGRect — приняли Codable. Наконец, компилятор Swift может синтезировать соответствие Codable для структур, классов и перечислений, если типы их свойств или связанных значений соответствуют Codable.
Обратной стороной зависимости от встроенных возможностей кодирования всех этих типов и сгенерированного компилятором соответствия Codable является отсутствие контроля над форматом сериализованных данных. Система Codable работает лучше всего, если вы ищете простой способ сериализовать (и десериализовать) ваши данные, но у вас нет особых требований к тому, как именно данные должны быть представлены в их сериализованном формате. Если вы хотите взаимодействовать с внешними форматами данных, например, с JSON API, который вы не контролируете, Codable все равно может быть хорошим выбором, если формат лишь немного отклоняется от стандартов Swift. Если формат данных имеет слишком много несовместимостей с настройками системы Codable по умолчанию, все еще возможно построить ваш код сериализации на основе архитектуры Codable, но вам придется написать много пользовательского кода для кодирования и декодирования, чтобы это работало.
Ниже мы сначала рассмотрим, как система Codable может быть использована для сериализации «из коробки». Затем мы исследуем, как обертки свойств могут быть использованы для выборочной настройки формата сериализованных данных. Наконец, мы углубимся в процесс кодирования и декодирования.
Минимальный пример Link to heading
Давайте начнем с минимального примера того, как вы можете использовать систему Codable для кодирования экземпляра пользовательского типа в JSON.
AutomaticConformance Link to heading
Сделать один из ваших собственных типов кодируемым может быть так же просто, как привести его в соответствие с Codable. Если все хранимые свойства типа также являются кодируемыми, компилятор Swift автоматически сгенерирует код, который реализует протоколы Encodable и Decodable. Эта структура Coordinate хранит GPS-координаты:
struct Coordinate: Codable {
var latitude: Double
var longitude: Double
// Здесь ничего реализовывать не нужно.
}
Поскольку оба хранимых свойства уже являются кодируемыми, принятие протокола Codable достаточно для удовлетворения компилятора. Теперь мы можем написать структуру Placemark, которая использует соответствие Codable структуры Coordinate:
struct Placemark: Codable {
var name: String
var coordinate: Coordinate
}
Перечисления также получают выгоду от синтеза кода Codable, если у них нет связанных значений, или если их связанные значения соответствуют Codable. Например, мы могли бы определить следующее перечисление Surrounding с связанными значениями, добавить его в альтернативную версию Placemark и получить все соответствия Codable бесплатно:
enum Surrounding: Codable {
case land
case inlandWater(name: String)
case ocean(name: String)
}
struct Placemark2: Codable {
var name: String
var coordinate: Coordinate
var surrounding: Surrounding
}
Сгенерированный компилятором код не виден, но мы разберем его по частям немного позже в этой главе. Пока что относитесь к сгенерированному коду так, как вы бы относились к стандартной реализации для протокола в стандартной библиотеке, такой как Sequence.drop(while:) — вы получаете стандартное поведение бесплатно, но у вас есть возможность предоставить свою собственную реализацию.
Единственное существенное отличие между генерацией кода и «нормальными» стандартными реализациями заключается в том, что последние являются частью стандартной библиотеки, в то время как логика синтеза кода Codable находится в компиляторе. Перемещение кода в стандартную библиотеку потребовало бы более мощных API для рефлексии, чем те, которые в настоящее время есть в Swift, и даже если бы они существовали, рефлексия во время выполнения имеет свои недостатки (например, рефлексия, как правило, медленнее).
Тем не менее, перемещение как можно большей части определения языка из компилятора в библиотеки остается заявленной целью для Swift. Возможно, когда-нибудь мы получим макросистему, достаточно мощную, чтобы переместить всю систему Codable в стандартную библиотеку, но это, по крайней мере, несколько лет впереди. До тех пор синтез кода компилятора является прагматичным решением этой проблемы и тем, что имеет приложения, помимо Codable — тот же дизайн используется для автоматического соответствия Equatable и Hashable для структур и перечислений, а также для соответствия CaseIterable для перечислений.
Кодирование Link to heading
Swift поставляется с двумя встроенными кодировщиками: JSONEncoder и PropertyListEncoder (они определены в Foundation, а не в стандартной библиотеке). Кроме того, типы, соответствующие протоколу Codable, совместимы с NSKeyedArchiver из Cocoa. Мы сосредоточимся на JSONEncoder, поскольку JSON является наиболее распространенным форматом обмена данными в Интернете.
Вот как мы можем закодировать массив значений Placemark в JSON:
let places = [
Placemark(name: "Berlin", coordinate: Coordinate(latitude: 52, longitude: 13)),
Placemark(name: "Cape Town", coordinate: Coordinate(latitude: -34, longitude: 18))
]
do {
let encoder = JSONEncoder()
let jsonData = try encoder.encode(places) // 129 байт
let jsonString = String(decoding: jsonData, as: UTF8.self)
/*
[{"name":"Berlin","coordinate":{"longitude":13,"latitude":52}},
{"name":"Cape Town","coordinate":{"longitude":18,"latitude":-34}}]
*/
} catch {
print(error.localizedDescription)
}
Фактический шаг кодирования чрезвычайно прост: создайте (и, при необходимости, настройте) кодировщик и передайте ему значение для кодирования. JSON-кодировщик возвращает коллекцию байтов в виде экземпляра Data, который мы затем преобразуем в строку для отображения.
В дополнение к свойству для настройки формата вывода (красиво отформатированный и/или ключи, отсортированные лексикографически), JSONEncoder предоставляет параметры настройки для форматирования дат (включая временные метки ISO8601 или Unix epoch) и значений Data (например, Base64), а также параметры для обработки исключительных значений с плавающей запятой (бесконечность и NaN). Мы даже можем использовать опцию keyEncodingStrategy кодировщика, чтобы указать, что ключи должны быть преобразованы в snake_case, или можем передать собственную функцию преобразования ключей. Эти параметры всегда применяются ко всей иерархии значений, которые кодируются, т.е. вы не можете использовать их, чтобы указать, что дата в одном типе должна следовать другой схеме кодирования, чем в другом типе. Если вам нужна такая степень детализации, вам придется написать собственные реализации Codable для затронутых типов или настроить кодирование конкретных свойств с помощью обертки свойства (мы покажем пример этого ниже).
Стоит отметить, что все эти параметры конфигурации специфичны для JSONEncoder. У других кодировщиков будут разные параметры (или вообще не будет). Даже метод encode(_:) специфичен для кодировщика и не определен в каких-либо протоколах. Другие кодировщики могут решить вернуть строку или даже URL закодированного файла вместо значения Data.
На самом деле, JSONEncoder даже не соответствует протоколу Encoder. Вместо этого он является оберткой вокруг частного класса, который реализует протокол и выполняет фактическую работу по кодированию. Он был разработан таким образом, потому что кодировщик верхнего уровня должен предоставлять совершенно другой API (а именно, один метод для начала процесса кодирования), чем объект Encoder, который передается типам, соответствующим Codable, в процессе кодирования. Четкое разделение этих задач означает, что клиенты могут получать доступ только к API, которые соответствуют данной ситуации — например, тип, соответствующий Codable, не может перенастроить кодировщик в середине процесса кодирования, потому что публичный API конфигурации доступен только через кодировщик верхнего уровня. Apple позже формализовала концепцию кодировщиков и декодировщиков верхнего уровня в фреймворке Combine, который включает протоколы TopLevelEncoder и TopLevelDecoder. Перенос этих протоколов в стандартную библиотеку был предложен, но пока не осуществлен.
Декодирование Link to heading
Декодирующим эквивалентом JSONEncoder является JSONDecoder. Декодирование следует той же схеме, что и кодирование: создайте декодер и передайте ему что-то для декодирования. JSONDecoder ожидает экземпляр Data, содержащий текст JSON, закодированный в UTF-8, но, как мы только что видели с кодировщиками, другие декодеры могут иметь разные интерфейсы:
do {
let decoder = JSONDecoder()
let decoded = try decoder.decode([Placemark].self, from: jsonData)
// [Berlin (lat: 52.0, lon: 13.0), Cape Town (lat: -34.0, lon: 18.0)]
type(of: decoded) // Array<Placemark>
decoded == places // true
} catch {
print(error.localizedDescription)
}
Обратите внимание, что метод decoder.decode(_:from:) принимает два аргумента. В дополнение к входным данным, нам также нужно указать тип, который мы ожидаем получить (в данном случае это [Placemark].self). Это позволяет обеспечить полную безопасность типов на этапе компиляции. Утомительное преобразование из слабо типизированных данных JSON в конкретные типы данных, которые мы используем в нашем коде, происходит за кулисами. Сделать декодируемый тип явным аргументом метода декодирования — это осознанный выбор дизайна. Это не было строго необходимо, так как компилятор мог бы автоматически вывести правильный тип во многих ситуациях, но команда Swift решила, что увеличение ясности и избежание неоднозначности важнее, чем максимальная краткость.
Еще более важным, чем при кодировании, обработка ошибок имеет критическое значение во время декодирования. Существует множество вещей, которые могут пойти не так — от отсутствующих данных (обязательное поле отсутствует во входных данных JSON) до несоответствий типов (сервер неожиданно кодирует числа как строки) и полностью поврежденных данных. Ознакомьтесь с документацией по типу DecodingError, чтобы узнать, какие другие ошибки вы можете ожидать.
Настройка закодированного формата Link to heading
Иногда формат данных, используемый встроенными кодировщиками и декодировщиками, требует небольших изменений, чтобы соответствовать вашим требованиям, например, для взаимодействия с определенным JSON API. Мы уже упоминали выше, что JSONEncoder предлагает множество параметров конфигурации для распространенных вариантов представления данных в формате JSON. Если эти параметры не предоставляют то, что вам нужно, вам следует рассмотреть возможность использования обертки свойства для настройки сериализованного формата для конкретного свойства.
Например, мы можем захотеть представить значения типа Double в структуре Coordinate в виде строк. Для этого мы реализуем обертку свойства CodedAsString и вручную приведем ее к Codable, т.е. реализуем инициализатор init(from:) и метод encode(to:) самостоятельно:
@propertyWrapper
struct CodedAsString: Codable {
var wrappedValue: Double
init(wrappedValue: Double) {
self.wrappedValue = wrappedValue
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
guard let value = Double(str) else {
let error = EncodingError.Context(
codingPath: container.codingPath,
debugDescription: "Недопустимое строковое представление значения double"
)
throw EncodingError.invalidValue(str, error)
}
wrappedValue = value
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(String(wrappedValue))
}
}
Хотя это кажется большим объемом кода на первый взгляд, большая часть из этого — это стандартный код или обработка ошибок. Только две строки (вызов decode в init и вызов encode в encode(to:)) специфичны для преобразования данных из double в строку (или наоборот). В следующем разделе мы подробно рассмотрим процесс кодирования и декодирования и объясним, как работать с контейнерами кодирования.
Применение этой обертки свойства к значениям широты и долготы в нашей структуре Coordinate довольно просто:
struct Coordinate: Codable {
@CodedAsString var latitude: Double
@CodedAsString var longitude: Double
}
let jsonData = try encoder.encode(places)
let jsonString = String(decoding: jsonData, as: UTF8.self)
/*
[{"name":"Berlin","coordinate":{"longitude":"13.0",
"latitude":"52.0"}},{"name":"Cape Town",
"coordinate":{"longitude":"18.0","latitude":"-34.0"}}]
*/
Использование оберток свойств для настройки процесса кодирования и декодирования определенных свойств имеет два больших преимущества: во-первых, поведение кодирования по умолчанию по-прежнему применяется ко всем другим свойствам (в нашем примере Coordinate их нет, но в реальной жизни вы часто хотите настроить только одно из нескольких свойств). Во-вторых, мы можем легко повторно использовать преобразование, такое как CodedAsString, в других местах.
Если вам нужно больше настроек, чем обертки свойств могут предоставить для отдельных свойств, вам придется вручную реализовать init(from:) и encode(to:), что мы подробно опишем в следующем разделе.
Процесс кодирования Link to heading
Если вы используете систему Codable и вас устраивает поведение по умолчанию, вы можете остановиться на этом месте. Но чтобы понять, как настроить способ кодирования типов, нам нужно углубиться немного глубже. Как работает процесс кодирования? Что именно компилятор синтезирует, когда мы приводим тип к Codable?
Когда вы инициируете процесс кодирования, кодировщик вызывает метод encode(to: Encoder) значения, которое кодируется, передавая себя в качестве аргумента. Затем значение отвечает за то, чтобы закодировать себя в кодировщик в любом формате, который считает подходящим.
В нашем примере выше мы передаем массив значений Placemark кодировщику JSON:
let jsonData = try encoder.encode(places)
Кодировщик (или, скорее, его частный класс, соответствующий протоколу Encoder) теперь вызовет places.encode(to: self). Как массив знает, как закодировать себя в формате, который понимает кодировщик?
Контейнеры Link to heading
Давайте рассмотрим протокол Encoder, чтобы увидеть интерфейс, который кодировщик предоставляет для значения, которое кодируется:
/// Тип, который может кодировать значения в нативный формат для внешнего представления.
public protocol Encoder {
/// Путь кодирующих ключей, пройденный для достижения этой точки в кодировании.
var codingPath: [CodingKey] { get }
/// Любая контекстная информация, установленная пользователем для кодирования.
var userInfo: [CodingUserInfoKey: Any] { get }
/// Возвращает контейнер кодирования, подходящий для хранения
/// нескольких значений, ключованных по данному типу ключа.
func container<Key: CodingKey>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key>
/// Возвращает контейнер кодирования, подходящий для хранения
/// нескольких неключевых значений.
func unkeyedContainer() -> UnkeyedEncodingContainer
/// Возвращает контейнер кодирования, подходящий для хранения
/// одного примитивного значения.
func singleValueContainer() -> SingleValueEncodingContainer
}
Игнорируя на мгновение codingPath и userInfo, очевидно, что Encoder в основном является поставщиком контейнеров кодирования. Контейнер — это изолированный вид на хранилище кодировщика. Создавая новый контейнер для каждого значения, которое кодируется, кодировщик может убедиться, что значения не перезаписывают данные друг друга.
Существует три типа контейнеров:
→ Ключевые контейнеры — Эти контейнеры кодируют пары ключ-значение. Подумайте о ключевом контейнере как о специальном виде словаря. Ключевые контейнеры являются наиболее распространенными контейнерами.
Ключи в ключевом контейнере имеют строгую типизацию, что обеспечивает безопасность типов и автозаполнение. Кодировщик в конечном итоге преобразует ключи в строки (или целые числа), когда он записывает свой целевой формат (например, JSON), но это скрыто от клиентского кода. Изменение ключей вашего типа — это самый простой способ настроить, как он кодирует себя. Мы увидим пример этого ниже.
→ Неключевые контейнеры — Эти контейнеры кодируют несколько значений последовательно, опуская ключи. Подумайте о массиве закодированных значений. Поскольку нет ключей для идентификации значения, контейнеры декодирования должны заботиться о декодировании значений в том же порядке, в котором они были закодированы.
→ Контейнеры для одного значения — Эти контейнеры кодируют одно значение. Вы бы использовали их для типов, которые полностью определяются одним свойством. Примеры включают примитивные типы, такие как Int, и перечисления, которые являются RawRepresentable как примитивные значения.
Для каждого из трех типов контейнеров существует протокол, который определяет интерфейс, через который контейнер получает значения для кодирования. Вот определение SingleValueEncodingContainer:
```swift
/// Контейнер, который может поддерживать хранение и прямое кодирование одного
/// неключевого значения.
public protocol SingleValueEncodingContainer {
/// Путь кодирующих ключей, пройденный для достижения этой точки в кодировании.
var codingPath: [CodingKey] { get }
/// Кодирует нулевое значение.
mutating func encodeNil() throws
/// Базовые типы.
mutating func encode( _ value: Bool) throws
mutating func encode( _ value: Int) throws
mutating func encode( _ value: Int8) throws
mutating func encode( _ value: Int16) throws
mutating func encode( _ value: Int32) throws
mutating func encode( _ value: Int64) throws
mutating func encode( _ value: UInt) throws
mutating func encode( _ value: UInt8) throws
mutating func encode( _ value: UInt16) throws
mutating func encode( _ value: UInt32) throws
mutating func encode( _ value: UInt64) throws
mutating func encode( _ value: Float) throws
mutating func encode( _ value: Double) throws
mutating func encode( _ value: String) throws
mutating func encode<T: Encodable>( _ value: T) throws
}
Как вы можете видеть, протокол в основном объявляет множество перегрузок encode(_:) для различных типов: Bool, String и целочисленных и плавающих типов. Также есть вариант для кодирования нулевого значения. Каждый кодировщик и декодировщик должен поддерживать эти примитивные типы, и все типы, соответствующие протоколу Encodable, в конечном итоге должны быть сводимы к одному из этих типов. Предложение Swift Evolution, которое представило систему Codable, говорит: Эти… перегрузки предоставляют сильные, статические гарантии типов о том, что может быть закодировано (предотвращая случайные попытки закодировать недопустимый тип) и предоставляют список примитивных типов, которые общие для всех кодировщиков и декодировщиков, на которые пользователи могут полагаться.
Любое значение, которое не является одним из базовых типов, попадает в обобщенную перегрузку encode<T: Encodable>. Внутри контейнер в конечном итоге вызывает метод encode(to: Encoder) аргумента, и весь процесс начнется заново на одном уровне ниже, пока не останутся только примитивные типы. Но контейнер может обрабатывать типы с особыми требованиями по-разному. Например, именно в этот момент JSONEncoder проверяет, кодирует ли он значение Data, которое должно соблюдать настроенную стратегию кодирования, такую как Base64 (поведение по умолчанию для Data — закодировать себя в неключевой контейнер UInt8 байтов).
UnkeyedEncodingContainer и KeyedEncodingContainerProtocol имеют такую же структуру, как SingleValueEncodingContainer, но они предоставляют несколько дополнительных возможностей, таких как возможность создавать вложенные контейнеры. Если вы хотите написать кодировщик и декодировщик для другого формата данных, большая часть работы состоит в реализации этих контейнеров.
Как значение кодирует себя Link to heading
Возвращаясь к нашему примеру, верхний уровень типа, который мы кодируем, — это Array<Placemark>. Неключевой контейнер идеально подходит для массива (который, в конце концов, является последовательным списком значений), поэтому массив запрашивает у кодировщика один. Затем массив перебирает свои элементы и кодирует каждый элемент в контейнер. Вот как этот процесс выглядит в коде:
extension Array: Encodable where Element: Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
for element in self {
try container.encode(element)
}
}
}
Элементы массива — это экземпляры Placemark. Мы видели, что для непримитивных типов контейнер будет продолжать вызывать метод encode(to:) для каждого значения.
Синтезированный код Link to heading
Это приводит нас к коду, который компилятор синтезирует для структуры Placemark, когда мы добавляем соответствие протоколу Codable. Давайте разберем это шаг за шагом.
CodingKeys Link to heading
Первое, что компилятор генерирует, — это приватный вложенный перечисляемый тип с именем CodingKeys:
struct Placemark {
// ...
private enum CodingKeys: CodingKey {
case name
case coordinate
}
}
Перечисление содержит по одному случаю для каждого сохраненного свойства структуры. Эти случаи являются ключами для контейнера с кодированием по ключу. По сравнению со строковыми ключами, эти строго типизированные ключи гораздо безопаснее и удобнее в использовании, поскольку компилятор обнаружит опечатки. Однако кодировщики в конечном итоге должны иметь возможность преобразовывать ключи в строки или целые числа для хранения. Обработка этих преобразований — задача протокола CodingKey:
/// Тип, который может использоваться в качестве ключа для кодирования и декодирования.
public protocol CodingKey {
/// Строка, используемая в именованной коллекции (например, в словаре с ключами-строками).
var stringValue: String { get }
/// Значение, используемое в коллекции с индексами-целыми числами
/// (например, в словаре с ключами-целыми числами).
var intValue: Int? { get }
init?(stringValue: String)
init?(intValue: Int)
}
Все ключи должны предоставлять строковое представление. Опционально, тип ключа также может предоставить преобразование в и из целых чисел. Кодировщики могут выбирать использование целочисленных ключей, если это более эффективно, но они также могут игнорировать их и придерживаться строковых ключей (как это делает JSONEncoder). Сгенерированный компилятором код по умолчанию производит только строковые ключи.
Метод encode(to:) Link to heading
Вот код, который компилятор генерирует для метода encode(to:) структуры Placemark:
struct Placemark: Codable {
// ...
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys. self )
try container.encode(name, forKey: .name)
try container.encode(coordinate, forKey: .coordinate)
}
}
Основное отличие от версии с массивом заключается в том, что Placemark кодирует себя в именованный контейнер. Именованный контейнер является правильным выбором для большинства составных типов данных (структур и классов) с более чем одним свойством (замечательное исключение: Range использует неименованный контейнер для кодирования своих нижних и верхних границ). Обратите внимание, как код передает CodingKeys.self в кодировщик, когда запрашивает именованный контейнер. Все последующие команды кодирования в этом контейнере должны указывать ключ того же типа. Поскольку тип ключа обычно является приватным для типа, который кодируется, практически невозможно случайно использовать ключи кодирования другого типа при ручной реализации метода encode(to:).
Конечный результат процесса кодирования представляет собой дерево вложенных контейнеров, которые JSON-кодировщик может преобразовать в целевой формат: именованные контейнеры становятся JSON-объектами ( {…} ), неименованные контейнеры становятся JSON-массивами ( […] ), а контейнеры с единственным значением преобразуются в числа, логические значения, строки или null, в зависимости от их типа данных.
Инициализатор Theinit(from:) Link to heading
Когда мы вызываем try decoder.decode([Placemark].self, from: jsonData), декодер создает экземпляр типа, который мы передаем (в данном случае это [Placemark]), используя инициализатор, определенный в Decodable. Как и кодировщики, декодеры управляют деревом контейнеров декодирования, которые могут быть любым из трех знакомых типов: контейнеры с ключами, контейнеры без ключей или контейнеры с единственным значением.
Каждое значение, которое декодируется, рекурсивно проходит вниз по иерархии контейнеров и инициализирует свои свойства значениями, которые оно декодирует из своего контейнера. Если на любом этапе возникает ошибка (например, из-за несоответствия типов или отсутствующего значения), весь процесс завершается с ошибкой.
Вот как выглядит сгенерированный компилятором инициализатор декодирования для Placemark:
struct Placemark: Codable {
// ...
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
coordinate = try container.decode(Coordinate.self, forKey: .coordinate)
}
}
Raw-Representable и Enums Link to heading
Мы уже видели, как синтез кода Swift генерирует реализацию Codable для структур (и классов, которые работают аналогично). Однако есть исключение: если тип соответствует протоколу RawRepresentable и его RawValue является одним из примитивных кодируемых типов (а именно Bool, String, Float, Double или одним из целочисленных типов), то сырое значение кодируется напрямую в контейнер с одним значением.
Распространенный случай типов с сырым представлением, которые вы можете захотеть закодировать, — это перечисления (enums). У перечислений даже есть специальный синтаксис, позволяющий сделать их сырыми представлениями без необходимости вручную реализовывать инициализатор и свойство rawValue (см. главу о перечислениях для получения дополнительных деталей). Давайте посмотрим, как упрощенная версия перечисления Surrounding из вышеуказанного примера кодируется:
enum Surrounding2: String, Codable {
case land
case inlandWater
case ocean
}
struct Placemark2: Codable {
var name: String
var coordinate: Coordinate
var surrounding: Surrounding2
}
let berlin = Placemark2(
name: "Berlin",
coordinate: Coordinate(latitude: 52, longitude: 13),
surrounding: .land
)
let data = try JSONEncoder().encode(berlin)
String(decoding: data, as: UTF8.self)
/*
{"name":"Berlin","coordinate":{"longitude":13,"latitude":52},
"surrounding":"land"}
*/
Сырое значение для значения Surrounding2 (которое по умолчанию является именем случая перечисления) используется без изменений в качестве значения для ключа “surrounding” в JSON-словаре. Компилятор также использует тот же формат кодирования для сырых представлений структур или классов. Однако обратите внимание, что не все перечисления кодируются таким образом: если перечисление не является сырым представлением, его значение кодируется в контейнере с ключами. Ключом является имя случая перечисления, а значением является словарь, содержащий связанные значения случая перечисления:
enum Surrounding3: Codable {
case land
case inlandWater(name: String)
case ocean(name: String)
}
struct Placemark3: Codable {
var name: String
var coordinate: Coordinate
var surrounding: Surrounding3
}
let greatBlueHole = Placemark3(
name: "Great Blue Hole",
coordinate: Coordinate(latitude: 17.32278, longitude: -87.534444),
surrounding: .ocean(name: "Caribbean Sea")
)
let data2 = try JSONEncoder().encode(greatBlueHole)
String(decoding: data2, as: UTF8.self)
/*
{"name":"Great Blue Hole",
"coordinate":{"longitude":-87.534443999999993,
"latitude":17.322780000000002},
"surrounding":{"ocean":{"name":"Caribbean Sea"}}}
*/
Так же, как и со структурами, вы можете определить свои собственные ключи кодирования для перечисления, чтобы переименовать или пропустить случаи. Более того, вы можете определить типы CodingKeys, чтобы настроить метки связанных значений (или пропустить связанное значение, если у него есть значение по умолчанию). Например, мы могли бы добавить перечисление OceanCodingKeys к Surrounding3, чтобы переименовать метку связанного значения с “name” на что-то другое.
Руководство по соответствию Link to heading
Если ваш тип имеет специальные требования, вы всегда можете самостоятельно реализовать требования Encodable и Decodable. Приятно то, что автоматическая синтез кода не является делом все или ничего — вы можете выбирать, что переопределить, и взять остальное от компилятора.
Custom CodingKeys Link to heading
Самый простой способ контролировать, как тип кодирует себя, — это написать пользовательский CodingKeys enum (кстати, это не обязательно должен быть enum, хотя только для enum генерируются реализации протокола CodingKey). Предоставление пользовательских ключей кодирования — это быстрый и декларативный способ изменения того, как тип кодируется. Это позволяет нам:
→ переименовывать поля в закодированном выводе, присваивая им явное строковое значение, или
→ полностью пропускать поля, исключая их ключи из enum.
Чтобы назначить разные имена, нам также нужно указать enum с явным типом значения String. Например, это сопоставит name с “label” в JSON-выводе, оставляя сопоставление coordinate без изменений:
struct Placemark2: Codable {
var name: String
var coordinate: Coordinate
private enum CodingKeys: String, CodingKey {
case name = "label"
case coordinate
}
// Сгенерированные компилятором методы encode и decode
// будут использовать переопределенные CodingKeys.
}
А это пропустит имя placemark и закодирует только GPS-координаты, потому что мы не включили ключ name в enum:
struct Placemark3: Codable {
var name: String = "(Unknown)"
var coordinate: Coordinate
private enum CodingKeys: CodingKey {
case coordinate
}
}
Обратите внимание на значение по умолчанию, которое мы должны были назначить свойству name. Без него генерация кода для Decodable завершится неудачей, когда компилятор обнаружит, что не может присвоить значение name в инициализаторе.
Пропуск свойств во время кодирования может быть полезен для временных значений, которые можно легко пересчитать или которые не важны для хранения, таких как кэши или мемоизированные дорогостоящие вычисления. Компилятор достаточно умен, чтобы самостоятельно фильтровать ленивые свойства, но если вы используете обычные хранимые свойства для временных значений, вот как вы можете сделать это самостоятельно.
Реализации encode(to:) и init(from:)
Link to heading
Если вам нужно больше контроля, всегда есть возможность реализовать encode(to:) и/или init(from:) самостоятельно. В качестве примера мы настроим, как наш тип Placemark обрабатывает отсутствующие значения во время декодирования. Вот альтернативное определение типа Placemark, в котором свойство coordinate является опциональным:
struct Placemark4: Codable {
var name: String
var coordinate: Coordinate?
}
По умолчанию JSONDecoder инициализирует опциональное свойство целевого типа значением nil, если в входных данных нет соответствующего значения. Соответственно, наш сервер теперь может отправить нам JSON-данные, в которых поле “coordinate” отсутствует:
let validJSONInput = """
[
{ "name" : "Berlin" },
{ "name" : "Cape Town" }
]
"""
Когда мы просим JSONDecoder декодировать этот ввод в массив значений Placemark4, мы получим placemarks, у которых свойство coordinate равно nil. Пока все хорошо. Теперь предположим, что сервер также может отправить пустой JSON-объект вместо того, чтобы полностью пропустить значение, чтобы обозначить отсутствующее опциональное значение:
let invalidJSONInput = """
[
{
"name" : "Berlin",
"coordinate": {}
}
]
"""
Когда мы пытаемся декодировать это, декодер, ожидая поля “latitude” и “longitude” внутри объекта coordinate, спотыкается о пустой объект и выдает ошибку .keyNotFound:
do {
let inputData = invalidJSONInput.data(using: .utf8)!
let decoder = JSONDecoder()
_ = try decoder.decode([Placemark4].self, from: inputData)
} catch {
print(error.localizedDescription)
// Данные не могут быть прочитаны, так как они отсутствуют.
}
Чтобы это работало, мы можем переопределить инициализатор Decodable и явно поймать ожидаемую ошибку:
struct Placemark4: Codable {
var name: String
var coordinate: Coordinate?
// encode(to:) все еще синтезируется компилятором.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
do {
self.coordinate = try container.decodeIfPresent(Coordinate.self, forKey: .coordinate)
} catch DecodingError.keyNotFound {
self.coordinate = nil
}
}
}
Теперь декодер может успешно декодировать ошибочный JSON:
do {
let inputData = invalidJSONInput.data(using: .utf8)!
let decoder = JSONDecoder()
let decoded = try decoder.decode([Placemark4].self, from: inputData)
decoded // [Berlin (nil)]
} catch {
print(error.localizedDescription)
}
Обратите внимание, что другие ошибки, такие как полностью поврежденные входные данные или любые проблемы с полем name, все равно будут вызывать исключение.
Такой тип настройки является хорошим вариантом, если затрагивается только один или два типа, но он не масштабируется. Если у типа десятки свойств, вам придется писать ручной код для каждого поля, даже если вам нужно настроить только одно. Что делает приведенный выше пример особенно сложным для реализации, так это то, что есть два варианта JSON, которые мы хотим интерпретировать как nil для свойства coordinate: либо ключ “coordinate” полностью отсутствует в JSON, либо значение “coordinate” является пустым JSON-объектом.
Поскольку поле “coordinate” может отсутствовать, мы не можем использовать обертку свойства как более элегантный, многоразовый способ принять пустой JSON-объект как значение nil: даже если wrappedValue нашей обертки свойства является опциональным, сама обертка свойства не является таковой, и синтезированный код декодирования ожидает, что ключ для этого свойства будет присутствовать.
Если мы немного упростим наши требования и предположим, что ключ “coordinate” всегда присутствует в JSON-данных, мы можем обработать случай “пустого объекта как nil” без ручной реализации CodingKeys и init(from:) для Coordinate. Вместо этого мы создадим обертку свойства NilWhenKeyNotFound, которая соответствует Decodable:
@propertyWrapper
struct NilWhenKeyNotFound<Value: Decodable>: Decodable {
var wrappedValue: Value?
init(wrappedValue: Value?) {
self.wrappedValue = wrappedValue
}
init(from decoder: Decoder) throws {
do {
let container = try decoder.singleValueContainer()
self.wrappedValue = try container.decode(Value.self)
} catch DecodingError.keyNotFound {
self.wrappedValue = nil
}
}
}
Теперь мы можем добавить эту обертку свойства к свойству coordinate. Если декодирование coordinate вызывает ошибку keyNotFound, coordinate будет равно nil:
struct Placemark5: Decodable {
var name: String
@NilWhenKeyNotFound var coordinate: Coordinate?
}
Обратите внимание, что мы только привели Placemark5 к Decodable, поскольку наша обертка свойства NilWhenKeyNotFound в настоящее время поддерживает только декодирование. Однако добавление соответствия Encodable — это всего лишь вопрос реализации encode(to:), которая должна создать пустой контейнер с ключами в случае, если wrappedValue равно nil.
Для получения дополнительных советов о том, как работать с неаккуратными данными в рамках системы Codable, вы можете прочитать статью Дэйва Лайона на эту тему. Дэйв придумал общее решение на основе протоколов для именно этой проблемы. И если у вас есть контроль над входными данными, всегда лучше исправить проблему на источнике (заставить сервер отправить корректный JSON), чем пытаться исправить поврежденные данные на более позднем этапе.
Общие задачи программирования Link to heading
В этом разделе мы обсудим некоторые общие задачи, которые вы можете захотеть решить с помощью системы Codable, а также потенциальные проблемы, с которыми вы можете столкнуться.
Создание типов, которые вы не владеете, с поддержкой Codable Link to heading
Предположим, мы хотим заменить наш тип Coordinate на CLLocationCoordinate2D из фреймворка CoreLocation. CLLocationCoordinate2D имеет такую же структуру, как и Coordinate, поэтому имеет смысл не изобретать велосипед. Проблема в том, что CLLocationCoordinate2D не соответствует протоколу Codable. В результате компилятор теперь (правильно) будет жаловаться, что он не может автоматически сгенерировать соответствие Codable для Placemark5, потому что одно из его свойств не является Codable:
import CoreLocation
struct Placemark5: Codable {
var name: String
var coordinate: CLLocationCoordinate2D
}
// Ошибка: невозможно автоматически сгенерировать 'Decodable'/'Encodable'
// потому что 'CLLocationCoordinate2D' не соответствует.
Можем ли мы сделать так, чтобы CLLocationCoordinate2D соответствовал Codable, несмотря на то, что тип определен в другом модуле? Добавление недостающего соответствия в расширении вызывает ошибку:
extension CLLocationCoordinate2D: Codable { }
// Ошибка: реализация 'Encodable' не может быть автоматически
// сгенерирована в расширении в другом файле для типа.
Swift будет генерировать код только для соответствий, которые указаны либо в определении типа, либо в расширении в том же файле — в этом случае нам придется реализовать протоколы вручную. Но даже если бы этого ограничения не существовало, ретроактивное добавление соответствия Codable к типу, которым мы не владеем, вероятно, не является хорошей идеей. Что если Apple решит предоставить соответствие в будущей версии SDK? Вероятно, реализация Apple не будет совместима с нашей, что означает, что значения, закодированные с помощью нашей версии, не будут декодироваться с кодом Apple и наоборот. Это проблема, потому что декодер не может знать, какую реализацию он должен использовать — он только видит, что ему нужно декодировать значение типа CLLocationCoordinate2D.
Итай Фербер, разработчик в Apple, который написал большие части системы Codable, дает следующий совет:
Я бы на самом деле пошел еще дальше и рекомендовал бы, что каждый раз, когда вы собираетесь расширить тип, принадлежащий кому-то другому, с помощью Encodable или Decodable, вам почти наверняка следует написать обертку-структуру для него, если у вас нет разумных гарантий, что тип никогда не попытается соответствовать этим протоколам самостоятельно.
В следующем разделе мы увидим пример, который использует обертку-структуру. Что касается нашей текущей проблемы, давайте воспользуемся немного другим (но столь же безопасным) решением: мы предоставим собственную реализацию Codable для Placemark5, в которой мы закодируем значения широты и долготы напрямую. Это эффективно скрывает существование типа CLLocationCoordinate2D от кодировщиков и декодеров; с их точки зрения, это выглядит так, как будто свойства широты и долготы были определены непосредственно в Placemark5:
extension Placemark5 {
private enum CodingKeys: String, CodingKey {
case name
case latitude = "lat"
case longitude = "lon"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
// Кодируем широту и долготу отдельно.
try container.encode(coordinate.latitude, forKey: .latitude)
try container.encode(coordinate.longitude, forKey: .longitude)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
// Восстанавливаем CLLocationCoordinate2D из широты/долготы.
self.coordinate = CLLocationCoordinate2D(
latitude: try container.decode(Double.self, forKey: .latitude),
longitude: try container.decode(Double.self, forKey: .longitude)
)
}
}
Этот пример дает нам хорошее представление о шаблонном коде, который нам придется написать для каждого типа, если бы компилятор не генерировал его за нас (и сгенерированная реализация для протокола CodingKey все еще отсутствует здесь).
В качестве альтернативы мы могли бы использовать вложенный контейнер для кодирования координат. KeyedDecodingContainer имеет метод с именем nestedContainer(keyedBy:forKey:), который создает отдельный ключевой контейнер (с отдельным типом ключей кодирования) и хранит его под предоставленным ключом. Мы бы добавили второй enum для вложенных ключей и закодировали значения широты и долготы во вложенный контейнер (здесь мы показываем только реализацию Encodable; Decodable следует той же схеме):
struct Placemark6: Encodable {
var name: String
var coordinate: CLLocationCoordinate2D
private enum CodingKeys: CodingKey {
case name
case coordinate
}
// Ключи кодирования для вложенного контейнера.
private enum CoordinateCodingKeys: CodingKey {
case latitude
case longitude
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
var coordinateContainer = container.nestedContainer(
keyedBy: CoordinateCodingKeys.self, forKey: .coordinate)
try coordinateContainer.encode(coordinate.latitude, forKey: .latitude)
try coordinateContainer.encode(coordinate.longitude, forKey: .longitude)
}
}
С этим подходом мы фактически воссоздали способ, которым тип Coordinate кодирует себя внутри нашей оригинальной структуры Placemark, но без раскрытия вложенного типа системе Codable. Результирующий JSON идентичен в обоих случаях.
Как вы можете видеть, количество кода, который нам нужно написать для обоих альтернатив, значительное. Для этого конкретного примера мы рекомендуем другой подход, а именно придерживаться нашей пользовательской структуры Coordinate для хранения и соответствия Codable и предоставить тип CLLocationCoordinate2D клиентам в качестве вычисляемого свойства. Поскольку частное свойство _coordinate является кодируемым, мы получаем соответствие Codable бесплатно; все, что нам нужно сделать, это переименовать его ключ в enum CodingKeys. А свойство coordinate, доступное клиентам, имеет тип, который требуется нашим клиентам, но система Codable проигнорирует его, потому что это вычисляемое свойство:
struct Placemark7: Codable {
var name: String
private var _coordinate: Coordinate
var coordinate: CLLocationCoordinate2D {
get {
return CLLocationCoordinate2D(latitude: _coordinate.latitude,
longitude: _coordinate.longitude)
}
set {
_coordinate = Coordinate(latitude: newValue.latitude,
longitude: newValue.longitude)
}
}
private enum CodingKeys: String, CodingKey {
case name
case _coordinate = "coordinate"
}
}
Этот подход хорошо работает в данном случае, потому что CLLocationCoordinate2D является таким простым типом, и перевод между ним и нашим пользовательским типом несложен.
Сделать классы кодируемыми Link to heading
Мы видели в предыдущем разделе, что возможно (но не рекомендуется) ретроактивно привести любой тип значения к Codable. Однако это не относится к неконечным классам. В общем случае система Codable работает нормально с классами, но потенциальное существование подклассов добавляет еще один уровень сложности. Что произойдет, если мы попытаемся привести, скажем, UIColor к Decodable? (Мы временно игнорируем Encodable, так как это не относится к данной дискуссии; мы можем добавить это позже.) Мы получили этот пример из сообщения Джордана Роуза в рассылке swift-evolution.
Пользовательская реализация Decodable для UIColor может выглядеть так:
extension UIColor: Decodable {
private enum CodingKeys: CodingKey {
case red
case green
case blue
case alpha
}
// Ошибка: требование инициализатора 'init(from:)' может быть выполнено
// только с помощью `required` инициализатора в определении неконечного класса 'UIColor'
// и т.д.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let red = try container.decode(CGFloat.self, forKey: .red)
let green = try container.decode(CGFloat.self, forKey: .green)
let blue = try container.decode(CGFloat.self, forKey: .blue)
let alpha = try container.decode(CGFloat.self, forKey: .alpha)
self.init(red: red, green: green, blue: blue, alpha: alpha)
}
}
Этот код не компилируется, и у него есть несколько ошибок, которые в конечном итоге сводятся к одному неразрешимому конфликту: только требуемые инициализаторы могут удовлетворять требованиям протокола, а требуемые инициализаторы не могут быть добавлены в расширениях; они должны быть объявлены непосредственно в определении класса.
Требуемый инициализатор (отмеченный ключевым словом required) указывает на инициализатор, который должен реализовать каждый подкласс. Правило, что инициализаторы, определенные в протоколах, должны быть обязательными, гарантирует, что они, как и все требования протокола, могут быть динамически вызваны в подклассах. Компилятор должен гарантировать, что код вроде этого работает:
func decodeDynamic(_ colorType: UIColor.Type, from decoder: Decoder) throws -> UIColor {
return try colorType.init(from: decoder)
}
let color = decodeDynamic(SomeUIColorSubclass.self, from: someDecoder)
Для того чтобы этот динамический диспетчинг работал, компилятор должен создать запись для инициализатора в таблице диспетча класса. Эта таблица неконечных методов класса создается фиксированного размера, когда определение класса компилируется; расширения не могут добавлять записи ретроактивно. Вот почему требуемый инициализатор разрешен только в определении класса.
Короче говоря: невозможно ретроактивно привести неконечный класс к Codable. В сообщении в рассылке, на которое мы ссылались выше, Джордан обсуждает ряд сценариев, подробно описывающих, как Swift мог бы сделать это возможным в будущем — от разрешения требуемого инициализатора быть конечным (тогда ему не нужна была бы запись в таблице диспетча) до добавления проверок времени выполнения, которые бы ловили, если подкласс не предоставил назначенный инициализатор, который вызывает требуемый инициализатор.
Но даже тогда нам все равно придется иметь дело с тем фактом, что добавление соответствия Codable к типам, которыми вы не владеете, является проблематичным. Как и в предыдущем разделе, рекомендуемый подход — написать обертку-структуру для UIColor и сделать ее кодируемой.
Один из подходов заключается в том, чтобы хранить значение UIColor в свойстве обертки-структуры, а затем написать пользовательскую реализацию Codable, определяя ключи кодирования для красного, зеленого, синего и альфа-компонентов цвета. Однако мы также можем написать структуру, которая имеет хранимые свойства для компонентов цвета и выводит значение UIColor из компонентов по мере необходимости. Преимущество этого подхода в том, что мы можем полностью полагаться на синтез кода для обертки-структуры:
@propertyWrapper
struct CodedAsRGBA: Codable {
private var red: CGFloat = 0
private var green: CGFloat = 0
private var blue: CGFloat = 0
private var alpha: CGFloat = 0
var wrappedValue: UIColor {
get { UIColor(red: red, green: green, blue: blue, alpha: alpha) }
set { store(newValue) }
}
init(wrappedValue: UIColor) {
store(wrappedValue)
}
mutating func store(_ color: UIColor) {
let success: Bool = color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
if !success {
fatalError("Неверный формат цвета")
}
}
}
Обратите внимание, что эта простая обертка поддерживает только экземпляры UIColor, которые могут быть представлены в RGBA. Более полная версия этой обертки должна проверять цветовое пространство и хранить его вместе с соответствующими компонентами для рассматриваемого цветового пространства.
Определив эту обертку-структуру формально как обертку свойства, мы можем использовать ее таким образом, который будет прозрачным для внешнего мира:
struct ColoredRect: Codable {
var rect: CGRect
@CodedAsRGBA var color: UIColor
}
Кодирование массива значений ColoredRect производит следующий JSON-вывод:
let rects = [ColoredRect(rect: CGRect(x: 10, y: 20, width: 100, height: 200), color: .yellow)]
do {
let encoder = JSONEncoder()
let jsonData = try encoder.encode(rects)
let jsonString = String(decoding: jsonData, as: UTF8.self)
// [{"color":{"red":1,"alpha":1,"blue":0,"green":1},"rect":[[10,20],[100,200]]}]
} catch {
print(error.localizedDescription)
}
Декодирование полиморфных коллекций Link to heading
Мы видели, что декодеры требуют от нас передать конкретный тип значения, которое декодируется. Это интуитивно понятно: декодеру нужен конкретный тип, чтобы определить, какой инициализатор вызвать, и поскольку закодированные данные обычно не содержат информации о типе, тип должен быть предоставлен вызывающим кодом. Следствием этого акцента на строгой типизации является то, что в шаге декодирования нет полиморфизма.
Предположим, мы хотим закодировать массив представлений, где фактические экземпляры являются подклассами UIView, такими как UILabel или UIImageView:
let views: [UIView] = [label, imageView, button]
(Предположим на мгновение, что UIView и все его подклассы соответствуют Codable, что на данный момент не так.)
Если бы мы закодировали этот массив, а затем декодировали его снова, он не вышел бы в идентичной форме — конкретные типы элементов массива не сохранились бы. Декодер бы только создал обычные объекты UIView, потому что все, что он знает, это то, что тип декодированных данных должен быть [UIView].self.
Итак, как мы можем закодировать такую коллекцию полиморфных объектов? Лучший вариант — определить перечисление с одним случаем для каждого подкласса, который мы хотим поддерживать. Полезные нагрузки случаев перечисления хранят фактические объекты:
enum View: Codable {
case view(UIView)
case label(UILabel)
case imageView(UIImageView)
// ...
}
Мы также должны написать две вспомогательные функции для обертывания UIView в значение View и наоборот. Таким образом, передача исходного массива кодировщику и получение его от декодера занимает всего одну операцию map.
Обратите внимание, что это не динамическое решение: нам придется вручную обновлять перечисление View каждый раз, когда мы хотим поддерживать другой подкласс. Это неудобно, но имеет смысл, что мы вынуждены явно называть каждый тип, который наш код может принимать от декодера. Все остальное могло бы представлять потенциальный риск безопасности, поскольку злоумышленник мог бы использовать манипулированный архив для создания неизвестных объектов в нашей программе.
Резюме Link to heading
Способность Swift без труда конвертировать между нативными типами программы и общими форматами данных с минимальным количеством кода — по крайней мере в обычных случаях — избавляет нас от необходимости писать и поддерживать множество вспомогательного кода. Система Codable становится еще более мощной, если вы можете использовать Swift как на клиенте, так и на сервере: использование одних и тех же типов повсюду гарантирует, что все платформы производят совместимые форматы кодирования. Переопределение поведения по умолчанию всегда возможно, хотя иногда это бывает неудобно, когда вам нужно обрабатывать не-Codable типы, которые вы не определили сами.
В этой главе мы обсуждали только традиционные задачи архивирования, но стоит подумать о других приложениях, которые могут извлечь выгоду из стандартизированного способа преобразования значений в примитивные данные и наоборот. Например, вы могли бы использовать систему Decodable в качестве замены рефлексии для генерации SQL-запросов из декодируемых значений. Или вы могли бы написать декодер, который может генерировать случайные значения для каждого из примитивных типов данных и использовать это для генерации рандомизированных тестовых данных для ваших модульных тестов.
Тем не менее, система проявляет свои лучшие качества, когда вы используете ее для задачи, для которой она была разработана — работы с унифицированными данными в известном формате в полностью безопасном с точки зрения типов режиме. За пределами обычного случая существует момент, когда попытка настроить систему Codable под ваши требования становится более трудоемкой, чем сэкономленная работа. Команда Swift Core признала это в посте на форуме в марте 2021 года:
Команда посчитала важным поделиться тем, что, хотя Codable играет критическую роль в экосистеме Swift, команда не рассматривает Codable как конечное состояние сериализации в Swift. По замыслу, Codable решает только подмножество потребностей в сериализации, и Swift необходимо получить дополнительные возможности, чтобы иметь более полное решение для сериализации.
Команда хотела бы начать этот разговор с сообществом, чтобы собрать требования и обсудить будущие проекты и их компромиссы, начиная от улучшений для Codable до дополнительных инструментов и API. Наша цель — использовать информацию, собранную в этой теме, для формирования будущих предложений, которые приведут к более полному решению для сериализации в Swift.
Будет интересно увидеть, что выйдет из этого обсуждения.
Интероперабельность Link to heading
15 Link to heading
Одним из сильных сторон Swift является низкое трение при взаимодействии с Objective-C и C. Swift может автоматически связывать типы Objective-C с нативными типами Swift, а также может связываться со многими типами C. Это позволяет нам использовать существующие библиотеки и предоставлять удобный интерфейс поверх них.
В этой главе мы создадим обертку вокруг реализации CommonMark на C. CommonMark — это формальная спецификация для Markdown, который является популярным синтаксисом для форматирования простого текста. Если вы когда-либо писали пост на GitHub или Stack Overflow, вы, вероятно, использовали Markdown. После этого практического примера мы рассмотрим инструменты, которые стандартная библиотека предоставляет для работы с памятью, и увидим, как они могут быть использованы для взаимодействия с кодом на C.
Обертка C-библиотеки Link to heading
Способность Swift вызывать C-код позволяет нам воспользоваться множеством существующих C-библиотек. API часто громоздки, а управление памятью может быть сложным, но написание обертки вокруг интерфейса существующей библиотеки на Swift часто оказывается гораздо проще и требует меньше усилий, чем создание чего-то с нуля; при этом пользователи нашей обертки не заметят разницы в терминах безопасности типов или удобства использования по сравнению с полностью нативным решением. Все, что нам нужно, это начать с динамической библиотеки и ее заголовочных файлов C.
В нашем примере библиотека CommonMark C является эталонной реализацией спецификации CommonMark, которая одновременно быстрая и хорошо протестированная. В этом руководстве мы применим многослойный подход, чтобы сделать CommonMark доступным из Swift. Сначала мы создадим тонкий класс на Swift вокруг непрозрачных типов, которые предоставляет библиотека. Затем мы обернем этот класс с помощью перечислений Swift, чтобы предоставить более идиоматичный API.
Настройка PackageManager Link to heading
Настройка проекта Swift Package Manager для импорта библиотеки C не так сложна, как это было раньше, но все же включает в себя несколько шагов. Вот краткий обзор того, что требуется.
Первый шаг — установить библиотеку cmark с помощью Homebrew в качестве менеджера пакетов на macOS. Откройте терминал и введите следующую команду для установки библиотеки:
$ brew install cmark
Если вы используете другую операционную систему, попробуйте установить cmark через менеджер пакетов вашей системы. На момент написания cmark версии 0.30.2 была самой последней.
Далее настройте новый проект SwiftPM. Перейдите в директорию, где вы храните свой код. Затем введите следующие команды, чтобы создать подкаталог для проекта и создать пакет SwiftPM для исполняемого файла:
$ mkdir CommonMarkExample
$ cd CommonMarkExample
$ swift package init --type executable
На этом этапе вы можете ввести swift run, чтобы проверить, что все работает. Менеджер пакетов должен собрать и запустить программу, выводя “Hello, world!” в консоль.
Теперь вам нужно сообщить Swift о библиотеке cmark, чтобы вы могли вызывать ее из Swift. В C вы бы использовали #include для одного или нескольких заголовочных файлов библиотеки, чтобы сделать их объявления видимыми для вашего кода. Swift не может обрабатывать заголовочные файлы C напрямую; он ожидает, что зависимости будут модулями. Чтобы библиотека C или Objective-C была видима для компилятора Swift, библиотека должна предоставить файл modulemap в формате Clang. Среди прочего, modulemap перечисляет заголовочные файлы, которые составляют модуль.
Поскольку cmark не поставляется с modulemap, вашей следующей задачей будет создать цель SwiftPM для библиотеки cmark и написать modulemap. Эта цель не будет содержать никакого кода; ее единственная цель — действовать как обертка модуля для библиотеки cmark.
Откройте файл Package.swift и отредактируйте его так, чтобы он выглядел следующим образом:
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "CommonMarkExample",
dependencies: [],
targets: [
.executableTarget(
name: "CommonMarkExample",
dependencies: ["Ccmark"]),
.systemLibrary(
name: "Ccmark",
pkgConfig: "libcmark",
providers: [
.brew(["cmark"]),
.apt(["cmark"]),
]),
]
)
(Чтобы сократить список, мы убрали комментарии и тестовую цель, которую SwiftPM создает по умолчанию; вы можете оставить их, конечно.)
Вы добавили цель системной библиотеки для cmark в манифест пакета. В терминологии SwiftPM системные библиотеки — это библиотеки, установленные системными менеджерами пакетов, такими как Homebrew или APT на Linux. Цель системной библиотеки — это любая цель SwiftPM, которая ссылается на такую библиотеку. По соглашению, имена чисто оберток модулей, таких как эта, должны начинаться с C, поэтому цель называется Ccmark.
Параметр pkgConfig указывает имя конфигурационного файла, в котором менеджер пакетов может найти пути поиска заголовков и библиотек для импортируемой библиотеки. Директива providers является необязательной. Это подсказка для установки, которую менеджер пакетов может отобразить, когда целевая библиотека не установлена.
Обратите внимание, что вам также нужно включить цель “Ccmark” в качестве зависимости вашей основной исполняемой цели в манифесте пакета. Строка dependencies: ["Ccmark"] заботится об этом.
Далее создайте директорию для исходников цели системной библиотеки; именно здесь будет находиться modulemap:
$ mkdir Sources/Ccmark
Прежде чем писать modulemap, создайте заголовочный файл C с именем shim.h в новой директории. Он должен содержать только следующую строку:
#include <cmark.h>
Наконец, файл module.modulemap должен выглядеть следующим образом:
module Ccmark [system] {
header "shim.h"
link "cmark"
export *
}
Заголовок shim обходит ограничение, что modulemaps должны содержать абсолютные пути. В качестве альтернативы вы могли бы опустить shim и указать заголовок cmark напрямую в modulemap, как в header "/usr/local/include/cmark.h". Но тогда путь к cmark.h был бы жестко закодирован в modulemap. С shim менеджер пакетов считывает правильный путь поиска заголовков из файла pkg-config и добавляет его в вызов компилятора.
Теперь вы должны иметь возможность импортировать Ccmark и вызывать любой API cmark. Добавьте следующий фрагмент кода в main.swift, чтобы быстро проверить, работает ли все:
import Ccmark
let markdown = "*Hello World*"
let cString = cmark_markdown_to_html(markdown, markdown.utf8.count, 0)!
defer { free(cString) }
let html = String(cString: cString)
print(html)
Вернитесь в терминал и запустите программу:
$ swift run
Если вы видите <p><em>Hello World</em></p> в качестве вывода, вы только что успешно вызвали функцию C из Swift! И теперь, когда у вас есть рабочая установка, вы можете начать писать свою обертку на Swift. (Если вы хотите использовать Xcode для редактирования и запуска вашего кода, вы можете открыть директорию пакета напрямую в Xcode.)
Вы также можете встроить код C в пакет, создав цель языка C. В зависимости от библиотеки, заставить ее собираться может быть гораздо более трудоемкой задачей, но это имеет то преимущество, что пользователям пакета не нужно устанавливать динамическую библиотеку. Когда вы нацеливаетесь на платформу, такую как iOS — или даже при написании приложения для macOS — вы не можете предполагать, что динамическая библиотека, такая как CommonMark, установлена, и создание цели языка C является лучшим решением. Apple делает это для своего форка cmark, который является основой пакета SwiftMarkdown.
Обертка библиотеки CommonMark Link to heading
Теперь, когда все настроено, давайте начнем с обертки одной функции с более удобным интерфейсом. Функция cmark_markdown_to_html принимает текст в формате Markdown и возвращает результирующий HTML-код в виде строки. C-интерфейс выглядит следующим образом:
/// Преобразует 'text' (предполагается, что это строка с кодировкой UTF-8 длиной
/// 'len') из CommonMark Markdown в HTML, возвращая строку с нулевым терминатором,
/// закодированную в UTF-8. Ответственность за освобождение возвращенного буфера
/// лежит на вызывающем.
char *cmark_markdown_to_html(const char *text, size_t len, int options);
Когда Swift импортирует это объявление, он представляет C-строку в первом параметре как UnsafePointer к числу CChar (псевдоним для Int8 или UInt8, в зависимости от целевой платформы). Из документации мы знаем, что ожидается, что это будут кодовые единицы UTF-8. Параметр len принимает длину строки:
// Интерфейс функции в Swift.
func cmark_markdown_to_html(_ text: UnsafePointer<CChar>!, _ len: Int, _ options: Int32) -> UnsafeMutablePointer<CChar>!
Мы хотим, чтобы наша обертка работала со строками Swift, конечно, поэтому вы можете подумать, что нам нужно преобразовать строку Swift в указатель CChar перед передачей его в cmark_markdown_to_html. Однако связывание между нативными и C-строками — это такая распространенная операция, что Swift сделает это автоматически. Мы должны быть осторожны с параметром len, так как функция ожидает длину строки, закодированной в UTF-8, в байтах, а не как количество символов. Мы получаем правильное значение из представления строки utf8, и мы можем просто передать ноль для параметра options:
func markdownToHTML(input: String) -> String {
let outString = cmark_markdown_to_html(input, input.utf8.count, 0)!
defer { free(outString) }
return String(cString: outString)
}
Обратите внимание, что мы принудительно извлекаем указатель строки, который возвращает функция. Мы можем безопасно сделать это, потому что знаем, что cmark_markdown_to_html всегда возвращает действительную строку. Принудительное извлечение внутри метода позволяет пользователю библиотеки вызывать метод markdownToHTML, не беспокоясь о опциональных значениях — результат никогда не будет равен nil. Это то, что компилятор не может сделать автоматически для нас — указатели C и Objective-C без аннотаций нулевости всегда импортируются в Swift как неявно извлекаемые опционалы.
Автоматическое связывание нативных строк Swift с C-строками предполагает, что C-функция, которую вы хотите вызвать, ожидает, что строка будет закодирована в UTF-8. Это правильный выбор в большинстве случаев, но если C API предполагает другую кодировку, вы не сможете использовать автоматическое связывание. Тем не менее, часто довольно просто создать альтернативные форматы. Например, если C API ожидает массив кодовых точек UTF-16, вы можете использовать Array(string.utf16). Компилятор Swift автоматически свяжет массив Swift с ожидаемым C-массивом, при условии, что типы элементов совпадают.
Также обратите внимание, что мы вызываем free внутри markdownToHTML, чтобы освободить память, выделенную cmark_markdown_to_html для выходной строки. При взаимодействии с C API мы несем ответственность за соблюдение правил управления памятью библиотеки C — компилятор Swift не может помочь нам с этим.
Обертка cmark_nodeType Link to heading
В дополнение к прямому выводу HTML, библиотека cmark также предоставляет способ разбора текста Markdown в структурированное дерево элементов. Например, простой текст может быть преобразован в список блочных узлов, таких как абзацы, цитаты, списки, блоки кода, заголовки и так далее. Некоторые блочные элементы содержат другие блочные элементы (например, цитаты могут содержать несколько абзацев), в то время как другие содержат только встроенные элементы (например, заголовок может содержать выделенное слово). Ни один элемент не может содержать оба типа (например, встроенные элементы элемента списка всегда обернуты в элемент абзаца).
Библиотека C использует единственный тип данных, cmark_node, для представления любого узла. Он является непрозрачным, что означает, что авторы библиотеки решили скрыть его определение. Все, что мы видим в заголовках, — это функции, которые работают с cmark_node или возвращают указатели на него. Swift импортирует эти указатели как OpaquePointer. (Мы подробнее рассмотрим различия между многими типами указателей в стандартной библиотеке, такими как OpaquePointer и UnsafeMutablePointer, позже в этой главе.)
Давайте обернем узел в нативный тип Swift, чтобы упростить с ним работу. Как мы видели в главе о структурах и классах, нам нужно думать о семантике хранения всякий раз, когда мы создаем пользовательский тип: является ли тип значением или имеет смысл, чтобы экземпляры имели идентичность? В первом случае мы должны предпочесть структуру или перечисление, тогда как последний требует класса. Наш случай интересен: с одной стороны, узел документа Markdown является значением — два узла, имеющие одинаковый тип элемента и содержимое, должны быть неразличимы, следовательно, они не должны иметь идентичности. С другой стороны, поскольку мы не знаем внутренности cmark_node, нет простого способа сделать копию узла, поэтому мы не можем гарантировать семантику значений. По этой причине мы начинаем с класса. Позже мы напишем еще один уровень поверх этого класса, чтобы предоставить интерфейс с семантикой значений.
Наш класс хранит непрозрачный указатель и освобождает память, используемую cmark_node, в deinit, когда не остается ссылок на экземпляр этого класса. Мы освобождаем память только на уровне документа, потому что в противном случае мы могли бы освободить узлы, которые все еще используются. Освобождение документа также автоматически освободит всех детей рекурсивно. Обертывание непрозрачного указателя таким образом даст нам автоматическое управление счетчиком ссылок бесплатно:
public class Node {
let node: OpaquePointer
init(node: OpaquePointer) {
self.node = node
}
deinit {
guard type == CMARK_NODE_DOCUMENT else { return }
cmark_node_free(node)
}
}
Следующий шаг — обернуть функцию cmark_parse_document, которая разбирает текст Markdown и возвращает корневой узел документа. Она принимает те же аргументы, что и cmark_markdown_to_html: строку, ее длину и целое число, описывающее параметры разбора. Возвращаемый тип функции cmark_parse_document в Swift — OpaquePointer, который представляет узел:
func cmark_parse_document(
_ buffer: UnsafePointer<Int8>!,
_ len: Int,
_ options: Int32
) -> OpaquePointer!
Мы превращаем функцию в инициализатор для нашего класса:
public init(markdown: String) {
let node = cmark_parse_document(markdown, markdown.utf8.count, 0)!
self.node = node
}
Снова мы принудительно развертываем возвращаемый указатель, потому что уверены, что cmark_parse_document не потерпит неудачу — любая входная строка является допустимым Markdown. Как и многие C API, библиотека cmark не имеет аннотаций нулевости и четкой документации, указывающей, какие указатели могут быть NULL. Если вы сомневаетесь, является ли конкретный указатель нулевым или нет, хорошей идеей будет проверить свои предположения, изучив исходный код C библиотеки.
Как упоминалось выше, есть несколько интересных функций, которые работают с узлами. Например, есть функция, которая возвращает тип узла, такой как абзац или заголовок:
cmark_node_type cmark_node_get_type(cmark_node* node);
В Swift это выглядит так:
func cmark_node_get_type(_ node: OpaquePointer!) -> cmark_node_type
cmark_node_type — это C перечисление, которое имеет случаи для различных блочных и встроенных элементов, определенных в Markdown, а также один случай для обозначения ошибок:
typedef enum {
// Статус ошибки
CMARK_NODE_NONE,
// Блочные элементы
CMARK_NODE_DOCUMENT,
CMARK_NODE_BLOCK_QUOTE,
...
// Встроенные элементы
CMARK_NODE_TEXT,
CMARK_NODE_EMPH,
...
} cmark_node_type;
Swift импортирует простые C перечисления как структуры, содержащие одно свойство UInt32. Кроме того, для каждого случая в перечислении генерируется глобальная константа:
struct cmark_node_type: RawRepresentable, Equatable {
public init(_ rawValue: UInt32)
public init(rawValue: UInt32)
public var rawValue: UInt32
}
var CMARK_NODE_NONE: cmark_node_type { get }
var CMARK_NODE_DOCUMENT: cmark_node_type { get }
...
Перечисления импортируются как структуры, потому что перечисления в C на самом деле просто целые числа. Swift должен предполагать, что переменная перечисления в C имеет произвольное целочисленное значение, что является чем-то, с чем нативные перечисления Swift не предназначены для работы. Только перечисления, помеченные макросом NS_ENUM, используемым Apple в своих фреймворках Objective-C, импортируются как нативные перечисления Swift.
В Swift тип узла должен быть свойством типа Node, поэтому мы превращаем функцию cmark_node_get_type в вычисляемое свойство нашего класса:
var type: cmark_node_type {
cmark_node_get_type(node)
}
Теперь мы можем просто написать node.type, чтобы получить тип элемента.
Есть еще несколько свойств узла, к которым мы можем получить доступ. Например, если узел является списком, он может иметь один из двух типов списков: маркированный или упорядоченный. Все остальные узлы имеют тип списка “нет списка”. Снова Swift представляет соответствующее C перечисление как структуру, с верхним уровнем переменной для каждого случая, и мы можем написать аналогичное обертывающее свойство. В этом случае мы также предоставляем сеттер, который пригодится позже в этой главе:
var listType: cmark_list_type {
get { return cmark_node_get_list_type(node) }
set { cmark_node_set_list_type(node, newValue) }
}
Библиотека cmark предоставляет аналогичные функции для всех других свойств узла (таких как уровень заголовка, информация о заблокированном коде и URL-адреса и заголовки ссылок). Эти свойства часто имеют смысл только для определенных типов узлов, и мы можем выбрать, чтобы предоставить интерфейс либо с опциональным значением (например, для URL ссылки), либо с значением по умолчанию (например, уровень заголовка по умолчанию равен нулю). Эта нехватка типобезопасности иллюстрирует серьезную слабость C API библиотеки, которую мы можем смоделировать гораздо лучше в Swift. Мы поговорим об этом ниже.
Некоторые узлы также могут иметь дочерние элементы. Чтобы перебрать их, библиотека CommonMark предоставляет функции cmark_node_first_child и cmark_node_next. Мы хотим, чтобы наш класс Node предоставлял массив своих дочерних элементов. Чтобы сгенерировать этот массив, мы начинаем с первого дочернего элемента и продолжаем добавлять дочерние элементы, пока либо cmark_node_first_child, либо cmark_node_next не вернет nil, тем самым сигнализируя о конце списка. Обратите внимание, что указатель, возвращаемый cmark_node_next, автоматически преобразуется в опциональный, где nil представляет собой нулевой указатель:
var children: [Node] {
var result: [Node] = []
var child = cmark_node_first_child(node)
while let unwrapped = child {
result.append(Node(node: unwrapped))
child = cmark_node_next(child)
}
return result
}
Мы также могли бы выбрать возвращать ленивую последовательность вместо массива (например, используя sequence или AnySequence). Однако с этой идеей есть проблема: структура узла может измениться между созданием и потреблением этой последовательности. В таком случае итератор для поиска следующего узла будет возвращать неправильные значения или, что еще хуже, вызывать сбой. В зависимости от вашего случая использования, возвращение лениво построенной последовательности может быть именно тем, что вам нужно, но если ваша структура данных может измениться, возвращение массива — это гораздо более безопасный выбор.
С этим простым классом-оберткой для узлов доступ к абстрактному синтаксическому дереву, созданному библиотекой CommonMark из Swift, становится гораздо проще. Вместо того чтобы вызывать функции, такие как cmark_node_get_list_type, мы можем просто написать node.listType и получить автозаполнение и типобезопасность. Однако мы еще не закончили. Хотя класс Node кажется гораздо более нативным, чем функции C, Swift позволяет нам выразить узел еще более естественным и безопасным способом, используя перечисления с ассоциированными значениями.
A Safer Interface Link to heading
Как уже упоминалось выше, существует множество свойств узлов, которые применимы только в определенных контекстах. Например, нет смысла получать уровень заголовка списка или тип списка для блока кода. Как мы видели в главе о перечислениях, перечисления с ассоциированными значениями позволяют нам указывать только ту метадату, которая имеет смысл для каждого конкретного случая. Мы создадим одно перечисление для всех возможных встроенных элементов и другое для элементов блочного уровня. Эти два перечисления будут публичным интерфейсом нашей библиотеки, тем самым превращая класс Node в внутреннюю деталь реализации.
Таким образом, мы можем обеспечить структуру документа CommonMark. Например, элемент простого текста просто хранит строку, в то время как узлы акцента содержат массив других встроенных элементов, но не могут иметь дочерние элементы блочного уровня. Вот перечисление для встроенных элементов:
public enum Inline {
case text(text: String)
case softBreak
case lineBreak
case code(text: String)
case html(text: String)
case emphasis(children: [Inline])
case strong(children: [Inline])
case custom(literal: String)
case link(children: [Inline], title: String?, url: String)
case image(children: [Inline], title: String?, url: String)
}
Для элементов блочного уровня также существуют специфические правила относительно того, какие другие элементы они могут содержать. Параграфы и заголовки могут содержать только встроенные элементы, в то время как блочные цитаты всегда содержат другие элементы блочного уровня. Наша модель Block отражает эти ограничения:
public enum Block {
case list(items: [[Block]], type: ListType)
case blockQuote(items: [Block])
case codeBlock(text: String, language: String?)
case html(text: String)
case paragraph(text: [Inline])
case heading(text: [Inline], level: Int)
case custom(literal: String)
case thematicBreak
}
Обратите внимание, что список определяется как массив элементов списка, где каждый элемент списка представлен массивом элементов Block. ListType — это простое перечисление, которое указывает, является ли список упорядоченным или неупорядоченным:
public enum ListType {
case unordered
case ordered
}
Поскольку перечисления являются типами значений, этот дизайн также позволяет нам рассматривать узлы как значения, преобразуя их в их представления в виде перечислений. Напомним, что это было невозможно с классом-оберткой вокруг непрозрачного указателя узла. Мы следуем Руководству по проектированию API, используя инициализаторы для преобразования типов. Мы пишем две пары инициализаторов: одна пара создает значения Block и Inline из класса Node, а другая пара восстанавливает Node из этих перечислений. Это позволяет нам писать функции, которые создают или манипулируют значениями Inline или Block, а затем позже восстанавливают документ CommonMark, состоящий из экземпляров Node, с целью использования библиотеки C для рендеринга документа в HTML или обратно в текст Markdown.
Давайте начнем с написания инициализатора, который преобразует Node в элемент Inline. Мы переключаемся по типу узла и создаем соответствующее значение Inline. Например, для текстового узла мы берем строковое содержимое узла, к которому мы получаем доступ через свойство literal в библиотеке cmark. Мы можем безопасно развернуть literal, потому что знаем, что текстовые узлы всегда имеют это значение, в то время как другие типы узлов могут вернуть nil из literal. Например, узлы акцента и сильного акцента имеют только дочерние узлы и не имеют значения literal. Чтобы разобрать последнее, мы проходим по дочерним узлам узла и рекурсивно вызываем наш инициализатор. Вместо дублирования этого кода мы создаем вспомогательную функцию inlineChildren, которая вызывается только при необходимости. Мы пропустили большинство других случаев ради краткости. Случай по умолчанию никогда не должен быть достигнут, поэтому мы выбираем остановить программу, если это произойдет. Это соответствует соглашению о том, что возврат опционального значения или использование throws следует использовать только для ожидаемых ошибок, а не для обозначения ошибок программиста:
extension Inline {
init(_ node: Node) {
let inlineChildren = { node.children.map(Inline.init) }
switch node.type {
case CMARK_NODE_TEXT:
self = .text(text: node.literal!)
case CMARK_NODE_STRONG:
self = .strong(children: inlineChildren())
case CMARK_NODE_IMAGE:
self = .image(children: inlineChildren(),
title: node.title, url: node.urlString)
// ... (больше случаев)
default:
fatalError("Неизвестный узел: \(node.typeString)")
}
}
}
Преобразование элементов блочного уровня следует тому же шаблону. Обратите внимание, что элементы блочного уровня могут иметь встроенные элементы, элементы списка или другие элементы блочного уровня в качестве дочерних, в зависимости от типа узла. В синтаксическом дереве cmark_node элементы списка оборачиваются в дополнительный узел. Мы убираем этот уровень в свойстве listItem на Node и напрямую возвращаем массив элементов блочного уровня:
extension Block {
init(_ node: Node) {
let parseInlineChildren = { node.children.map(Inline.init) }
switch node.type {
case CMARK_NODE_PARAGRAPH:
self = .paragraph(text: parseInlineChildren())
case CMARK_NODE_LIST:
let type: ListType = node.listType == CMARK_BULLET_LIST ? .unordered : .ordered
self = .list(items: node.children.map { $0.listItem }, type: type)
case CMARK_NODE_HEADING:
self = .heading(text: parseInlineChildren(), level: node.headerLevel)
// ... (больше случаев)
default:
fatalError("Неизвестный узел: \(node.typeString)")
}
}
}
Теперь, имея узел на уровне документа, мы можем преобразовать его в массив элементов Block:
extension Node {
public var elements: [Block] {
children.map(Block.init)
}
}
Элементы Block являются значениями: мы можем свободно копировать или изменять их, не беспокоясь о ссылках. Это очень мощно для манипуляции узлами. Поскольку значения, по определению, не заботятся о том, как они были созданы, мы также можем создать синтаксическое дерево Markdown в коде, с нуля, не используя библиотеку CommonMark вообще. Типы также гораздо яснее; вы не можете случайно делать вещи, которые не имеют смысла — такие как доступ к заголовку списка — так как компилятор этого не позволит. Помимо того, что ваш код становится безопаснее, это очень надежная форма документации — просто взглянув на типы, очевидно, как структурирован документ CommonMark. И, в отличие от комментариев, компилятор гарантирует, что эта форма документации никогда не устареет.
Теперь легко писать функции, которые работают с нашими новыми типами данных. Например, если мы хотим построить список всех заголовков первого и второго уровня из документа Markdown для оглавления, мы можем пройтись по всем дочерним элементам и проверить, являются ли они заголовками и имеют ли правильный уровень:
func tableOfContents(document: String) -> [Block] {
let blocks = Node(markdown: document).children.map(Block.init) ?? []
return blocks.filter {
switch $0 {
case .heading(_, let level) where level < 3: return true
default: return false
}
}
}
Но прежде чем мы создадим больше операций, давайте рассмотрим обратное преобразование: преобразование Block обратно в Node. Нам нужно это преобразование, потому что в конечном итоге мы хотим использовать библиотеку CommonMark для генерации HTML или других текстовых форматов из синтаксического дерева Markdown, которое мы построили или изменили, и библиотека может работать только с cmark_node_type.
Наш план состоит в том, чтобы добавить два инициализатора в Node: один, который преобразует значение Inline в узел, и другой, который обрабатывает элементы Block. Мы начинаем с расширения Node новым инициализатором, который создает новый cmark_node с нуля с указанным типом и дочерними элементами. Напомним, что мы написали deinit, который освобождает корневой узел дерева (и рекурсивно все его дочерние узлы). Этот deinit гарантирует, что узел, который мы выделяем здесь, будет освобожден в конечном итоге:
extension Node {
convenience init(type: cmark_node_type, children: [Node] = []) {
self.init(node: cmark_node_new(type))
for child in children {
cmark_node_append_child(node, child.node)
}
}
}
Нам часто нужно создавать узлы только с текстом или узлы с несколькими дочерними элементами, поэтому давайте добавим три удобных инициализатора, чтобы упростить это:
extension Node {
convenience init(type: cmark_node_type, literal: String) {
self.init(type: type)
self.literal = literal
}
convenience init(type: cmark_node_type, blocks: [Block]) {
self.init(type: type, children: blocks.map(Node.init))
}
convenience init(type: cmark_node_type, elements: [Inline]) {
self.init(type: type, children: elements.map(Node.init))
}
}
Теперь мы можем использовать удобные инициализаторы, которые мы только что определили, чтобы написать инициализаторы преобразования. Мы переключаемся по входным данным и создаем узел с правильным типом. Вот версия для встроенных элементов:
extension Node {
convenience init(element: Inline) {
switch element {
case .text(let text):
self.init(type: CMARK_NODE_TEXT, literal: text)
case .emphasis(let children):
self.init(type: CMARK_NODE_EMPH, elements: children)
case .html(let text):
self.init(type: CMARK_NODE_HTML_INLINE, literal: text)
case .custom(let literal):
self.init(type: CMARK_NODE_CUSTOM_INLINE, literal: literal)
case let .link(children, title, url):
self.init(type: CMARK_NODE_LINK, elements: children)
self.title = title
self.urlString = url
// ... (больше случаев)
}
}
}
Создание узла из элемента блочного уровня похоже. Единственный немного более сложный случай — это списки. Напомним, что в приведенном выше преобразовании из Node в Block мы убрали дополнительный узел, который библиотека CommonMark использует для представления списков, поэтому нам нужно добавить его обратно:
extension Node {
convenience init(block: Block) {
switch block {
case .paragraph(let children):
self.init(type: CMARK_NODE_PARAGRAPH, elements: children)
case let .list(items, type):
let listItems = items.map { Node(type: CMARK_NODE_ITEM, blocks: $0) }
self.init(type: CMARK_NODE_LIST, children: listItems)
listType = type == .unordered ? CMARK_BULLET_LIST : CMARK_ORDERED_LIST
case .blockQuote(let items):
self.init(type: CMARK_NODE_BLOCK_QUOTE, blocks: items)
case let .codeBlock(text, language):
self.init(type: CMARK_NODE_CODE_BLOCK, literal: text)
fenceInfo = language
// ... (больше случаев)
}
}
}
Наконец, чтобы предоставить красивый интерфейс для пользователя, мы определяем публичный инициализатор, который принимает массив элементов блочного уровня и создает узел документа, который мы затем можем отрендерить в один из различных форматов вывода:
extension Node {
public convenience init(blocks: [Block]) {
self.init(type: CMARK_NODE_DOCUMENT, blocks: blocks)
}
}
Теперь мы можем двигаться в обоих направлениях: мы можем загрузить документ, преобразовать его в элементы [Block], изменить эти элементы и снова превратить их в Node. Это позволяет нам писать программы, которые извлекают информацию из Markdown или даже динамически изменяют Markdown.
Создав сначала тонкую обертку вокруг библиотеки C (класс Node), мы абстрагировали преобразование от базового C API. Это позволило нам сосредоточиться на предоставлении интерфейса, который ощущается как идиоматический Swift. Весь проект доступен на GitHub.
Обзор низкоуровневых типов Link to heading
В стандартной библиотеке существует множество типов, которые предоставляют низкоуровневый доступ к памяти. Их огромное количество может быть подавляющим, как и их устрашающие названия, такие как UnsafeMutableRawBufferPointer. Хорошая новость заключается в том, что они названы последовательно, поэтому назначение каждого типа можно deduce из его названия. Вот самые важные части именования:
→ Управляемый тип имеет автоматическое управление памятью. Компилятор позаботится о выделении, инициализации и освобождении памяти за вас.
→ Небезопасный тип избегает обычных функций безопасности Swift, таких как проверки границ или гарантии инициализации перед использованием. Он также не предоставляет автоматического управления памятью — вам нужно явно выделять, инициализировать, деинициализировать и освобождать память.
→ Буферный тип работает с несколькими (непрерывно хранящимися) элементами, а не с одним элементом, и предоставляет интерфейс Collection.
→ Указатель типа имеет семантику указателя (как указатель C).
→ Сырой тип содержит не типизированные данные. Это эквивалент void* в C. Типы, которые не содержат “raw” в своем названии, имеют типизированные данные и являются обобщенными по своему соответствующему типу элемента.
→ Изменяемый тип позволяет изменять память, на которую он указывает.
Если вам нужен прямой доступ к памяти, но не нужно взаимодействовать с C, вы можете использовать класс ManagedBuffer для выделения памяти. Это похоже на то, что используют типы коллекций стандартной библиотеки под капотом для управления своей памятью. Он состоит из одного заголовочного значения (для хранения данных, таких как количество элементов) и непрерывной памяти для элементов. У него также есть свойство capacity, которое не является тем же самым, что и количество фактических элементов: например, массив с количеством 17 может иметь буфер с емкостью 32, что означает, что можно добавить еще 15 элементов, прежде чем массиву придется выделить больше памяти. Также есть вариант под названием ManagedBufferPointer, но у него не так много применений вне стандартной библиотеки, и он может быть удален в будущем.
Иногда вам нужно выполнять ручное управление памятью. Например, вы можете захотеть передать объект Swift в функцию C с целью его последующего извлечения. Чтобы обойти отсутствие замыканий в C, C API, использующие обратные вызовы (указатели на функции), часто принимают дополнительный аргумент контекста (обычно не типизированный указатель, т.е. void*), который они передают в обратный вызов. Когда вы вызываете такую функцию из Swift, было бы удобно иметь возможность передать нативный объект Swift в качестве значения контекста, но C не может напрямую обрабатывать объекты Swift. Здесь на помощь приходит тип Unmanaged. Это обертка для экземпляра класса, которая предоставляет сырой указатель на себя, который мы можем передать в C. Поскольку объекты, обернутые в Unmanaged, живут вне системы управления памятью Swift, нам нужно вручную следить за балансом вызовов retain и release. Мы рассмотрим пример этого в следующем разделе.
Указатели Link to heading
В дополнение к типу OpaquePointer, который мы уже рассмотрели, в Swift есть еще восемь типов указателей, которые соответствуют различным классам указателей C. Основной тип, UnsafePointer, аналогичен указателю const в C. Он является универсальным для типа данных, на который указывает память, поэтому UnsafePointer соответствует const int*. Обратите внимание, что C различает const int* (изменяемый указатель на неизменяемые данные, т.е. вы не можете записывать в данные, на которые указывает этот указатель) и int* const (неизменяемый указатель, т.е. вы не можете изменить, на что указывает этот указатель). UnsafePointer в Swift эквивалентен первому варианту. Как всегда, вы контролируете изменяемость самого указателя, объявляя переменную с помощью var или let.
Автоматическое преобразование указателей для аргументов функций Link to heading
Вы можете создать UnsafePointer из одного из других типов указателей, используя инициализатор. Swift также поддерживает специальный синтаксис для вызова функций, которые принимают небезопасные указатели. Вы можете передать любую изменяемую переменную правильного типа в такую функцию, предварительно добавив к ней амперсанд, тем самым сделав её выражением inout:
var x = 5
func fetch(p: UnsafePointer<Int>) -> Int {
p.pointee
}
fetch(p: &x) // 5
Это выглядит точно так же, как параметры inout, которые мы рассмотрели в главе о функциях, и работает аналогичным образом — хотя в этом случае ничего не передается обратно вызывающему через это значение, потому что указатель не является изменяемым. Указатель, который Swift создает за кулисами и передает функции, гарантированно будет действительным только на время вызова функции. Не пытайтесь вернуть указатель из функции и получить к нему доступ после того, как функция вернулась — результат будет неопределенным.
Существует также изменяемый вариант, UnsafeMutablePointer. Эта структура работает так же, как обычный, не константный указатель C; вы можете разыменовать указатель и изменить значение памяти, которое затем будет передано обратно вызывающему через выражение inout:
func increment(p: UnsafeMutablePointer<Int>) {
p.pointee += 1
}
var y = 0
increment(p: &y)
y // 1
Цикл жизни указателя (ThePointerLifecycle) Link to heading
Вместо использования выражения in-out, вы можете напрямую выделить память, используя UnsafeMutablePointer. Правила выделения памяти в Swift аналогичны правилам в C: после выделения памяти вам сначала нужно инициализировать её, прежде чем вы сможете её использовать. После того как вы закончите с указателем, вам нужно освободить память:
// Выделите и инициализируйте память для двух Int.
let z = UnsafeMutablePointer<Int>.allocate(capacity: 2)
z.initialize(repeating: 42, count: 2)
z.pointee // 42
// Арифметика указателей:
(z + 1).pointee = 43
// Индексация:
z[1] // 43
// Освободите память.
z.deallocate()
// Не обращайтесь к pointee после освобождения.
Если тип Pointee указателя (тип данных, на который он указывает) является нетривиальным типом, который требует управления памятью (например, класс или структура, содержащая класс), вам также необходимо вызвать deinitialize перед вызовом deallocate. Методы initialize и deinitialize выполняют операции подсчета ссылок, которые обеспечивают работу ARC. Забыв вызвать deinitialize, вы можете вызвать утечку памяти. Ещё хуже, если не использовать initialize — например, присвоив значение неинициализированной памяти через индексатор указателя — это может привести ко всем видам неопределенного поведения или сбоев.
Raw Pointers Link to heading
В C API также часто встречается указатель на последовательность байтов без конкретного типа элемента (void* или const void*). Эквивалентные типы в Swift — это UnsafeMutableRawPointer и UnsafeRawPointer. C API, которые используют void* или const void*, импортируются как эти типы. Если вам действительно не нужно работать с сырыми байтами, вы обычно напрямую преобразуете эти типы в Unsafe[Mutable]Pointer или другие типизированные варианты, например, с помощью load(fromByteOffset:as:).
Optionals Represent Nullable Pointers Link to heading
В отличие от C, Swift использует опционалы для различения нулевых и ненулевых указателей. Только значения с типом опционального указателя могут представлять нулевой указатель. Внутри, память для UnsafePointer и Optional<UnsafePointer> имеет одинаковую структуру; компилятор достаточно умен, чтобы сопоставить случай .none с битовым шаблоном, состоящим из всех нулей, для нулевого указателя.
Непрозрачные указатели (OpaquePointers) Link to heading
Иногда в C API есть тип непрозрачного указателя. Например, в библиотеке cmark мы видели, что тип cmark_node* импортируется как OpaquePointer. Поскольку определение cmark_node не раскрыто в заголовочном файле библиотеки C, компилятор Swift не знает о расположении памяти этого типа, и поэтому не может позволить нам получить доступ к памяти, на которую указывает указатель. Вы можете преобразовать непрозрачные указатели в другие указатели, используя инициализатор.
OpaquePointer в Swift на самом деле менее безопасен с точки зрения типов, чем соответствующий код на C, потому что непрозрачные указатели, импортированные из C, теряют свою информацию о типе. В C cmark_node* и cmark_iter* являются различными типами, и компилятор предупредит вас, если вы попытаетесь передать один из них в функцию, ожидающую другой. Swift импортирует оба типа как OpaquePointer и рассматривает их как один и тот же тип. В идеале OpaquePointer должен быть обобщенным, чтобы OpaquePointer<cmark_node> и OpaquePointer<cmark_iter> были различимы в системе типов.
BufferPointers Link to heading
В Swift мы обычно используем тип Array для хранения последовательности значений последовательно. В C массив часто возвращается как указатель на первый элемент и количество элементов. Если мы хотим использовать такую последовательность как коллекцию, мы могли бы преобразовать последовательность в Array, но это создаст копию элементов. Это часто является хорошей практикой (потому что, как только они находятся в массиве, элементы управляются памятью средой выполнения Swift). Однако иногда вы не хотите создавать копии каждого элемента. Для таких случаев существуют типы Unsafe[Mutable]BufferPointer. Вы инициализируете их указателем на первый элемент и количеством. С этого момента у вас есть (изменяемая) коллекция с произвольным доступом. Указатели буфера значительно упрощают работу с коллекциями C.
Array поставляется с методами withUnsafe[Mutable]BufferPointer, которые предоставляют (изменяемый) доступ к хранилищу массива через указатель буфера. Эти API позволяют вам копировать элементы в массив или из него оптом, или игнорировать проверку границ, что может повысить производительность в циклах. Swift 5 сделал эти методы доступными в обобщенных контекстах в виде withContiguous[Mutable]StorageIfAvailable. Имейте в виду, что, используя один из этих методов, вы обходите все обычные проверки безопасности, которые выполняют коллекции Swift.
Наконец, типы Unsafe[Mutable]RawBufferPointer упрощают работу с необработанной памятью как с коллекциями (они предоставляют низкоуровневый эквивалент типа Data в Foundation).
Замыкания как C-обработчики событий Link to heading
Давайте рассмотрим конкретный пример C API, который использует указатели. Наша цель — написать обертку на Swift для функции сортировки qsort из стандартной библиотеки C. Тип, как он импортируется в модуле Darwin Swift (или, если вы на Linux, в Glibc), приведен ниже:
public func qsort(
_ __base: UnsafeMutableRawPointer!, // массив, который нужно отсортировать
_ __nel: Int, // количество элементов
_ __width: Int, // размер каждого элемента
_ __compar: **@escaping @convention** (c) (UnsafeRawPointer?,
UnsafeRawPointer?) // функция сравнения
-> Int32)
Страница man (man qsort) описывает, как использовать функцию qsort: Функции qsort() и heapsort() сортируют массив из nel объектов, первый элемент которого указывается указателем base. Размер каждого объекта задается width. Содержимое массива base сортируется в порядке возрастания в соответствии с функцией сравнения, на которую указывает compar, которая требует два аргумента, указывающих на сравниваемые объекты.
А вот метод-обертка, который использует qsort для сортировки массива строк Swift:
extension Array where Element == String {
mutating func quickSort() {
qsort(& self , count, MemoryLayout<String>.stride) { a, b in
let l = a!.assumingMemoryBound(to: String. self ).pointee
let r = b!.assumingMemoryBound(to: String. self ).pointee
if r > l { return -1 }
else if r == l { return 0 }
else { return 1 }
}
}
}
Давайте рассмотрим каждый из аргументов, передаваемых в qsort: → Первый аргумент — это указатель на первый элемент массива, который должен быть отсортирован на месте. Компилятор может автоматически преобразовать массивы Swift в указатели в стиле C, когда вы передаете их в функцию, которая принимает UnsafePointer. Мы должны использовать префикс & потому что это UnsafeMutableRawPointer (аналог void* base в C-декларации). Если бы функция не нуждалась в изменении своего входного значения, и если бы она была объявлена в C как const void* base, амперсанд не был бы нужен. Это соответствует различию с аргументами inout в функциях Swift; обычные аргументы не используют амперсанд, но аргументы inout требуют префикса амперсанда.
→ Во-вторых, мы должны указать количество элементов. Это просто; мы можем использовать свойство count массива.
→ В-третьих, чтобы получить ширину каждого элемента, мы используем MemoryLayout.stride, а не MemoryLayout.size. В Swift MemoryLayout.size возвращает истинный размер типа, но при расположении элементов в памяти правила выравнивания платформы могут привести к пробелам между соседними элементами. Ширина — это размер типа плюс некоторый отступ (который может быть равен нулю), чтобы учесть этот пробел. Для строк размер и ширина в настоящее время одинаковы на платформах Apple, но это не будет верно для всех типов — например, размер кортежа (Int32, Bool) равен 5, тогда как его ширина равна 8. При переводе кода из C в Swift вы, вероятно, захотите использовать MemoryLayout.stride в случаях, когда вы бы использовали sizeof в C.
→ Последний параметр — это указатель на C-функцию, которая используется для сравнения двух элементов из массива. Swift автоматически связывает тип функции Swift с указателем на C-функцию, поэтому мы можем передать любую функцию, которая имеет соответствующую сигнатуру. Однако есть одно большое ограничение: указатели на функции C — это просто указатели; они не могут захватывать никакие значения. По этой причине компилятор позволит вам предоставлять только функции, которые не захватывают никакое внешнее состояние (например, никаких локальных переменных и никаких обобщений). Swift обозначает это с помощью атрибута @convention(c). Функция compar принимает два необработанных указателя. Такой UnsafeRawPointer может быть указателем на что угодно. Причина, по которой нам нужно работать с UnsafeRawPointer (а не с UnsafePointer), заключается в том, что в C нет обобщений. Однако мы знаем, что нам передается String, поэтому мы можем интерпретировать его как указатель на String. Мы также знаем, что указатели здесь никогда не равны nil, поэтому мы можем безопасно разыменовать их. Наконец, функция должна возвращать Int32: положительное число, если первый элемент больше второго, ноль, если они равны, и отрицательное число, если первый меньше второго.
Универсальность Link to heading
Создать другой обертку, которая работает с другим типом элементов, достаточно просто; мы можем скопировать и вставить код и изменить String на другой тип, и дело сделано. Но на самом деле нам нужно сделать код универсальным. Здесь мы сталкиваемся с ограничением указателей на функции в C. Приведенный ниже код не компилируется, потому что функция сравнения стала замыканием; она теперь захватывает вещи из-за пределов своей области видимости. Более конкретно, она захватывает операторы сравнения и равенства, которые различны для каждого конкретного типа, с которым она вызывается. Ничего не поделаешь — мы просто столкнулись с врожденным ограничением C:
extension Array where Element: Comparable {
mutating func quickSort() {
// Ошибка: указатель на функцию C не может быть сформирован
// из замыкания, которое захватывает универсальные параметры.
qsort(&self, self.count, MemoryLayout<Element>.stride) { a, b in
let l = a!.assumingMemoryBound(to: Element.self).pointee
let r = b!.assumingMemoryBound(to: Element.self).pointee
if r > l { return -1 }
else if r == l { return 0 }
else { return 1 }
}
}
}
Один из способов подумать об этом ограничении — это мыслить как компилятор. Указатель на функцию C — это просто адрес в памяти, который указывает на блок кода. Для функций, которые не имеют контекста, этот адрес будет статическим и известным на этапе компиляции. Однако в случае универсальной функции передается дополнительный параметр (универсальный тип). Следовательно, нет фиксированных адресов для специализированных универсальных функций. То же самое касается замыканий. Даже если бы компилятор мог переписать замыкание таким образом, чтобы его можно было передать как указатель на функцию, управление памятью не могло бы быть выполнено автоматически — нет способа узнать, когда освободить замыкание.
На практике это проблема для многих программистов на C. В macOS есть вариант qsort, называемый qsort_b, который принимает блок — замыкание — вместо указателя на функцию в качестве последнего параметра. Если мы заменим qsort на qsort_b в приведенном выше коде, он скомпилируется и будет работать нормально.
Однако qsort_b недоступен на большинстве платформ, поскольку блоки не являются частью стандарта C. И другие функции, кроме qsort, также могут не иметь варианта на основе блоков. Большинство C API, которые работают с обратными вызовами, предлагают другое решение: они принимают дополнительный UnsafeRawPointer в качестве параметра и передают этот указатель в функцию обратного вызова. Пользователь API может затем использовать этот параметр, чтобы передать произвольный кусок данных в каждое вызов функции обратного вызова. qsort также имеет вариант, qsort_r, который делает именно это. Его сигнатура типа включает дополнительный параметр, thunk, который является UnsafeMutableRawPointer. Обратите внимание, что этот параметр также был добавлен к типу указателя на функцию сравнения, потому что qsort_r передает значение этой функции при каждом вызове:
public func qsort_r(
_ __base: UnsafeMutableRawPointer!,
_ __nel: Int,
_ __width: Int,
_ __thunk: UnsafeMutableRawPointer!,
_ __compar: @escaping @convention(c)
(UnsafeMutableRawPointer?, UnsafeRawPointer?, UnsafeRawPointer?) -> Int32
)
Если qsort_b недоступен на нашей целевой платформе, мы можем восстановить его функциональность в Swift, используя qsort_r. Мы можем передать что угодно в качестве параметра thunk, если мы приведем его к UnsafeRawPointer. В нашем случае мы хотим передать замыкание для сравнения. Напомним, что тип Unmanaged может связывать нативные объекты Swift и сырые указатели, что именно нам и нужно. Поскольку Unmanaged работает только с классами, нам нужно обернуть наше замыкание в класс. Мы можем повторно использовать класс Box из главы о свойствах для этого:
@propertyWrapper
class Box<A> {
var wrappedValue: A
init(wrappedValue: A) {
self.wrappedValue = wrappedValue
}
}
С помощью приведенного выше кода мы можем начать писать наш вариант qsort_b. Чтобы придерживаться схемы именования C, мы назовем функцию qsort_block. Вот реализация:
typealias Comparator = (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32
func qsort_block(_ array: UnsafeMutableRawPointer, _ count: Int,
_ width: Int, _ compare: @escaping Comparator) {
let box = Box(wrappedValue: compare) // 1
let unmanaged = Unmanaged.passRetained(box) // 2
defer {
unmanaged.release() // 6
}
qsort_r(array, count, width, unmanaged.toOpaque()) {
(ctx, p1, p2) -> Int32 in // 3
let innerUnmanaged = Unmanaged<Box<Comparator>>.fromOpaque(ctx!) // 4
let comparator = innerUnmanaged.takeUnretainedValue().wrappedValue // 4
return comparator(p1, p2) // 5
}
}
Функция выполняет следующие шаги:
0. Она оборачивает замыкание компаратора в экземпляр Box.
- Затем она оборачивает
boxв экземплярUnmanaged. ВызовpassRetainedгарантирует, что экземплярboxбудет сохранен, чтобы он не был освобожден преждевременно (помните, вы несете ответственность за сохранение объектов, которые вы помещаете в экземплярUnmanaged). - Далее она вызывает
qsort_r, передавая указатель на объектUnmanagedв качестве параметраthunk(Unmanaged.toOpaqueвозвращает сырой указатель на себя). - Внутри обратного вызова
qsort_rона преобразует сырой указатель обратно в объектUnmanaged, извлекаетboxи распаковывает замыкание. Убедитесь, что вы не изменяете счетчик ссылок на обернутый объект. Поток точно обратен шагам 1-3, так какfromOpaqueвозвращает объектUnmanaged,takeUnretainedValueизвлекает экземплярBox, аwrappedValueраспаковывает замыкание. - После этого она вызывает функцию компаратора с двумя элементами, которые должны быть сравнены.
- Наконец, после того как
qsort_rвозвращает, в блокеdeferона освобождает экземплярbox, теперь, когда он больше не нужен. Это уравновешивает операцию удержания из шага 2.
Теперь мы можем изменить нашу функцию qsortWrapper, чтобы использовать qsort_block и предоставить красивый универсальный интерфейс для алгоритма qsort из стандартной библиотеки C:
extension Array where Element: Comparable {
mutating func quickSort() {
qsort_block(&self, self.count, MemoryLayout<Element>.stride) { a, b in
let l = a!.assumingMemoryBound(to: Element.self).pointee
let r = b!.assumingMemoryBound(to: Element.self).pointee
if r > l { return -1 }
else if r == l { return 0 }
else { return 1 }
}
}
}
var numbers = [3, 1, 4, 2]
numbers.quickSort()
numbers // [1, 2, 3, 4]
Может показаться, что это много работы, чтобы использовать алгоритм сортировки из стандартной библиотеки C. В конце концов, встроенная функция сортировки Swift гораздо проще в использовании, и она быстрее в большинстве случаев. Это, безусловно, правда, но есть много других интересных C API, которые мы можем обернуть с помощью типобезопасного и универсального интерфейса, используя ту же технику.
Резюме Link to heading
Переписывание существующей библиотеки на C с нуля на Swift, безусловно, увлекательно, но это может быть не лучшим использованием вашего времени (если только вы не делаете это ради обучения, что совершенно замечательно). Существует много хорошо протестированного кода на C, и выбрасывать его было бы огромной тратой. Swift отлично взаимодействует с кодом на C, так почему бы не воспользоваться этими возможностями? Тем не менее, нельзя отрицать, что большинство API на C кажется очень чуждыми в Swift. Более того, вероятно, не лучшая идея распространять конструкции C, такие как указатели и ручное управление памятью, по всей вашей кодовой базе.
Написание небольшого обертки, которая обрабатывает небезопасные части внутренне и предоставляет идиоматический интерфейс Swift — как мы сделали в этой главе для библиотеки Markdown — дает вам лучшее из обоих миров: вам не нужно изобретать велосипед (т.е. писать полный парсер Markdown), но при этом это ощущается на 100 процентов нативно для разработчиков, использующих API.
Заключительные слова Link to heading
16 Link to heading
Мы надеемся, что вам понравилось это путешествие по Swift вместе с нами. Несмотря на свою молодость, Swift уже является сложным языком. Было бы трудной задачей охватить все его аспекты в одной книге, не говоря уже о том, чтобы ожидать, что читатели запомнят все. Но даже если вы не сразу примените все, что узнали, мы уверены, что лучшее понимание вашего языка делает вас более опытным программистом.
Если вы вынесете что-то из этой книги, мы надеемся, что это то, что многие продвинутые аспекты Swift предназначены для того, чтобы помочь вам писать лучший, более безопасный и более выразительный код. Хотя вы можете писать код на Swift, который не сильно отличается от Objective-C, Java или C#, мы надеемся, что мы убедили вас в том, что такие функции, как перечисления, обобщения, функции первого класса и структурированная конкуренция, могут значительно улучшить ваш код.
Модель конкурентности, возможно, является последней крупной особенностью Swift на некоторое время, но это не значит, что язык не будет продолжать развиваться. Вот некоторые области, в которых мы ожидаем значительных улучшений в ближайшие годы:
→ Расширения модели конкурентности. Проверка на этапе компиляции будет постепенно увеличиваться, чтобы дать разработчикам библиотек время для аудита их кода на предмет конкурентности, и команда Swift уже работает над распределенными акторами, которые являются расширением модели акторов для нескольких процессов или машин.
→ Дополнения к системе обобщений. Мы упоминаем некоторые из этих дополнений в книге, такие как ограничения для непрозрачных типов и снятие ограничений на экзистенциальные типы.
→ Явный контроль над управлением памятью и владением. Цель состоит в том, чтобы предоставить компилятору всю необходимую информацию, чтобы избежать ненужных копий при передаче значений в функции, тем самым сделав Swift более подходящим для написания низкоуровневого кода с жесткими требованиями к производительности.
→ Более мощная интроспекция. Компилятор встраивает много метаданных о типах и их свойствах в бинарные файлы. Эта информация уже используется инструментами отладки, но пока нет публичных API для доступа к ней. Наличие этих данных открывает двери для более мощных возможностей рефлексии и интроспекции, которые выходят за рамки того, что может сделать текущий тип Mirror.
→ Значительные улучшения в API строк. Команда Swift работает над поддержкой регулярных выражений и синтаксисом на основе построителей результатов для написания парсеров под названием декларативная обработка строк.
Если вы заинтересованы в том, как будут развиваться эти и другие функции, помните, что Swift разрабатывается открыто. Рассмотрите возможность присоединиться к Swift Forums и добавить свою точку зрения в обсуждения.
Наконец, мы хотели бы призвать вас воспользоваться тем фактом, что Swift является открытым исходным кодом. Когда у вас есть вопрос, на который документация не отвечает, исходный код часто может дать вам ответ. Если вы дошли до этого места, у вас не будет проблем с тем, чтобы разобраться в исходных файлах стандартной библиотеки. Возможность проверить, как реализованы вещи, была большой помощью для нас при написании этой книги.