Основы Swift / 18.2. Ограничения типов


Видео


Ограничения типов
Функция swapTwoValues(_:_:) и тип Stack могут работать с любым типом. Однако, иногда бывает полезно принудительно задать ограничения на типы, которые могут использоваться с шаблонными функциями и типами. Ограничения типа определяют, что параметр типа должен наследоваться от заданного класса или адаптировать определённый протокол или композицию протоколов.

Например, стандартный тип Swift накладывает ограничение на типы, которые могут быть использованы в качестве ключей для словаря. Тип ключей словаря должен быть хэшируемым. Dictionary требует от своих ключей быть хэшируемыми, чтобы проверить, содержится ли в нём или нет значение по заданному ключу. Без этого требования Dictionary не мог быть сказать, следует ли ему вставить или заменить значения для конкретного ключа, а также не способен найти значение для заданного ключая, который уже находится в словаре.

Этот требование обеспечивается ограничением типа на типе ключа для Dictionary, который определяет, что этот тип ключа должен соответствовать протоколу Hashable, специальному протоколу, заданному в стандартной библиотеке Swift. Все базовые типы Swift (такие как String, Int, Double и Bool) хэшируемы по-умолчанию.

Вы можете определить свои собственные ограничения на тип, когда создаёте собственные обобщённые типы, и эти ограничения предоставляют большую силу обобщённого программирования. Абстрактные концепции наподобие Hashable характеризуют типы в терминах их концептуальных характеристик, а не их конкретный тип.
Hashable
Вы можете использовать Ваши собственные типы в качестве значений для множестве или ключей для словарей, сделав их соответствующими протоколов Hashable стандартной библиотеки Swift. Типы, удовлетворяющие протоколу Hashable должны предоставить получаемое свойство Int с именем hashValue. Значение, возвращаемое свойством hashValue не всегда обязано быть одинаковым между разными запусками одной программы или в разных программах.

Так как протокол Hashable удовлетворяет протоколу Equatable, удовлетворяющие типы обязаны также предоставить реализацию оператора равенства (==). Протокол Equatable требует от любой удовлетворяющей реализации оператора эквивалентности == быть отношением эквивалентности. Это значит, что реализация == должна удовлетворить следующим трём условиям для всех a, b и c:
1. a == a (Рефлексивность)
2. a == b следовательно b == a (Симметричность)
3. a == b && b == c следовательно a == c (Транзитивность)
Синтаксис ограничений типа
Вы можете написать ограничения типа, разместив один класс- или протокол-ограничение после имени параметра типа, отделив их двоеточием в качестве списка параметров типа. Базовый синтаксис ограничений на шаблонной функции покажет ниже (хотя этот же синтаксис идентичен обобщённым типам):
@{18.2\1}
func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // function body goes here
}
Гипотетическая функция выше имеет два параметра типа. Первый параметр типа, T, имеет ограничение типа, которое требует от T быть подклассом класса SomeClass. Второй параметр типа, U, имеет ограничение типа, которое требует от U адаптировать протокол SomeProtocol.
Ограничения типов в действии
Здесь нешаблонная функция с названием findIndex(ofString:in:), которая получает строковые значение для поиска и массива значений String, внутри которого его нужно найти. Функция findIndex(ofString:in:) возвращает опциональное значение типа Int, на котором находится индекс первой совпадающий в массиве, если строка была найдена в нём, или nil, если строка не может быть найдена:
@{18.2\2\1}
func findIndex(ofString valueToFind: String, in array: [String]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}
@{18.2\2\2}
Функция findIndex(ofString:in:) может быть использована для нахождения строкового значения в массиве строк:
let strings = ["cat", "dog", "llama", "parakeet", "terrapin"]
if let foundIndex = findIndex(ofString: "llama", in: strings) {
    print("The index of llama is \(foundIndex)")
}
// Выведет "The index of llama is 2"
Однако, принцип поиска индекса значения в массиве полезен не только для строк. Вы можете написать ту же функциональность для шаблонной функции, заменив использование строк значениями какого-то типа T.
@{18.2\2\3}
Здесь представлена, как Вы могли бы ожидать шаблонную версию findIndex(ofString:in:) с названием findIndex(of:in:). Заметьте, что возвращаемый тип остаётся быть Int?, так как функция возвращаем опциональный номер индекса, а не опциоалньое значение из массива. Будьте осторожны, так как эта функция не скомпилируется ввиду причин, которые будут описаны посыле примера:
func findIndex<T>(of valueToFind: T, in array:[T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}
Эта функция не скомпилируется, как написано выше. Проблема заключается в проверке равенства “if value == valueToFind”. Не каждый тип в Swift может быть сравнен с помощью оператора (==). Если Вы создадите Ваш собственный класс или структуру для представления сложной модели данных, например, когда значения "равен" для класса или структуры не такое, как Swift может угадать за Вас. Ввиду этого невозможно гарантировать, что этот код будет работать для всякого типа T, так что подходящая ошибка будет выведена, когда Вы попытаетесь собрать этот код.
@{18.2\2\4}
Однако, не всё потеряно. Стандартная библиотека Swift предоставляет протокол с названием Equatable, который требует от каждого подходящего типа реализовать оператор равенства (==) и оператор неравенства (!=) для сравнения любых двух объектов этого типа. Все стандартные типы Swift автоматически поддерживают протокол Equatable.

Любой тип, являющийся Equatable, может быть безопасно использовать с функцией findIndex(of:in:), так как он гарантированно поддерживает оператор равенства. Для выражения этого факта, Вы должны написать ограничение типа на протокол Equatable в качестве части определения параметра типа, когда Вы задаёте функцию:
func findIndex<T: Equatable>(of valueToFind: T, in array:[T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}
Это единственный тип параметра для findIndex(of:in:) записывается как T: Equatable, что значит "любой тип T, который адаптирует протокол Equatable".
@{18.2\2\5}
Функция findIndex(of:in:) теперь успешно скомпилируется и может быть использована с любым типом, являющимся Equatable, как например Double или String:
let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
// doubleIndex is an optional Int with no value, because 9.3 isn't in the array
let stringIndex = findIndex(of: "Andrea", in: ["Mike", "Malcolm", "Andrea"])
// stringIndex is an optional Int containing a value of 2"
Ассоциированные типы
Когда Вы определяете протокол, то бывает иногда полезным объявить один или более ассоциированных типов в качестве определения протокола. Ассоциированный тип даёт замещаемое имя для типа, который может быть использован в качестве части протокола. Конкретный тип ассоциированного типа не будет определён, пока протокол не будет адаптирован. Ассоциированные типы определяются ключевым словом associatedtype.
@{18.2\3\1}
Здесь приведён пример протокола с названием Container, который объявляет ассоциированный тип с названием Item:
protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
Протокол Container определяет три требуемых возможности, которые должен представить любой контейнер:
  • Он должен быть способен добавить новый элемент в контейнер с помощью метода @code(append(_:)).
  • Он должен быть способен получить доступ к количеству элементов в контейнере через свойство @code(count), которое возвращает значение @code(Int).
  • Он должен быть способен получить каждый элемент в контейнер с помощью сабскрипта, который принимает значение индекса @code(Int).
Этот протокол не определяет, как должны храниться элементы в контейнере или какой тип должен быть этому позволен. Этот протокол только определяет три части функциональности, которые должен предоставить любой тип, чтобы быть признанным в качестве Container. Подходящий тип может предоставить дополнительную функциональность, пока он удовлетворяет этим трём требованиям.

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

Чтобы определить эти требования, протокол Container должен иметь способ для ссылки на тип элементов, которые будет хранить контейнер без знаний о том, каков этот тип для отдельного контейнера. Протокол Container должен определить, что любой тип, передаваемый в метод append(_:), должен иметь тот же тип, что и тип элемента контейнера, а также что значение, возвращаемое санскритом контейнера будет того же типа, что и тип элемента контейнера.

Чтобы достичь этого, протокол Container определяет ассоциированный тип с названием Item, что записывается как associatedtype Item, Протокол не определяет, чем на самом деле является Item: эта информация остаётся на любой адаптирующий тип. Однако ярлык Item позволяет ссылаться на тип объектов в Container и определить тип для использования с методом append(_:) и сабскриптом, чтобы быть уверенными, что ожидаемое поведение Container гарантируется.
@{18.2\3\2}
Здесь представлена версия нешаблонного типа IntStack, данного ранее, адаптированного для соответствия протоколу Container:
struct IntStack: Container {
    // original IntStack implementation
    var items = [Int]()
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }
    // conformance to the Container protocol
    typealias Item = Int
    mutating func append(_ item: Int) {
        self.push(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Int {
        return items[i]
    }
}
Тип IntStack реализует все три требования протокола Container, и в каждом случае заворачивает часть имеющейся функциональности типа IntStack для удовлетворения этим требованиям.

Более того, IntStack определяет для этой реализации Container, что подходящая Item для использования имеет тип Int. Определение typealias Item = Int превращает абстрактный тип Item в конкретный тип Int для этой реализации протокола Container.

Благодаря выводу типов Swift, Вам не нужно объявлять конкретно Item в качестве Int как часть определения IntStack. Так как IntStack удовлетворяет всем требованиям протокола Container, то Swift может вывести нужный Item для использования, просто распознав тип параметра item метода append(_:) и возвращаемого типа сабскрипта. Действительно, если Вы удалите строку typealias Item = Int из кода выше, всё продолжит работать, так как понятно, какой тип следует использовать для Item.
@{18.2\3\3}
Вы также можете сделать обобщённый тип Stack соответствующим протоколу Container:
struct Stack<Element>: Container {
    // original Stack<Element> implementation
    var items = [Element]()
    mutating func push(_ item: Element) {
        items.append(item)
    }
    mutating func pop() -> Element {
        return items.removeLast()
    }
    // conformance to the Container protocol
    mutating func append(_ item: Element) {
        self.push(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Element {
        return items[i]
    }
}
В этот раз параметр типа Element используется в качестве типа для параметра item метода append(_:) и в качестве возвращаемого значения для сабскрипта. Следовательно Swift может вывести, что Element подходит в качестве типа для Item в конкретном контейнере.
Расширение существующего типа для указания ассоциированного типа
Вы можете расширить существующей тип для добавления соответствия протоколу. Это включает в себя и протоколы с ассоциированными типами.
@{18.2\3\4}
Тип Swift Array уже имеет метод append(_:), свойство сount и сабскрипт с индексом Int для получения его элементов. Эти три возможности совпадают с требованиями протокола Container. Это значит, что Вы можете расширить Array для соответствия протоколу Container, просо объявив, что Array адаптирует этот протокол. Вы можете сделать это пустым расширением.
extension Array: Container {}
Существующий метод append(_:) у массива и сабскрипт позволяют Swift вывести подходящий тип для использования в качестве Item, как и в случае с шаблонным типом Stack выше. После определения этого расширения, Вы можете использовать любой Array как Container.
Использование аннотаций типов для ограничения ассоциированного типа
Вы можете добавить аннотацию типа для ассоциированного типа в протоколе, чтобы потребовать того, что адаптирующие типы должны удовлетворять ограничениям, описанным в аннотации типа. Например, следующий код определяет версию протокола Container, который требует от элементов в контейнере быть сравниваемыми на равенство:
@{18.2\4\1}
Чтобы соответствовать этой версии Container, тип Item должен удовлетворять протоколу Equatable.
protocol Container {
    associatedtype Item: Equatable
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}
Блоки шаблонов where
Ограничения типов позволяют Вам определить требования на параметры типов, ассоциированные с шаблонной функцией, санскритом или типом.

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

Пример ниже определяет шаблонную функцию с названием allItemsMatch, проверяющую то, если два объекта типа Container содержат одни и те же элементы в одном и том же порядке. Функция возвращает значение типа Bool true, если все элементы совпадают и значение false, если нет.
@{18.2\4\2}
Два контейнера проверяемых не должны иметь один и тот же тип (хотя они и могут), но они должны содержать элементы одного типа. Это требование выражается через комбинацию ограничений типов и блока шаблонов where:
func allItemsMatch<C1: Container, C2: Container>
    (_ someContainer: C1, _ anotherContainer: C2) -> Bool
    where C1.Item == C2.Item, C1.Item: Equatable {

        // Check that both containers contain the same number of items.
        if someContainer.count != anotherContainer.count {
            return false
        }

        // Check each pair of items to see if they are equivalent.
        for i in 0..< someContainer.count {
            if someContainer[i] != anotherContainer[i] {
                return false
            }
        }

        // All items match, so return true.
        return true
}
Эта функция принимает два аргумента с названиями someContainer и anotherContainer. Аргумент someContainer имеет тип C1, а anotherContainer имеет тип C2. И C1, и C2 являются параметрами типа для определения при вызове функции. @bt Следующие требования применяются к двум параметрам типа функции:
  1. С1 должен адаптировать протокол Container (записывается как C1: Container).
  2. С2 так же должен адаптировать протокол Container (записывается как C2: Container).
  3. Item для C1 должен совпадать с Item для C2 (записывается как C1.Item == C2.Item).
  4. Item для C1 должен удовлетворять протоколу Equatable (записывается как C1.Item: Equatable).
Первое и второе требования определены в списке параметров типа функции, а третье и четвёртое требования определены в блоке шаблона функции where.

Эти требования значат:
  1. someContainer - это контейнер типа C1.
  2. anotherContainer - это контейнер типа С2.
  3. someContainer и anotherContainer содержат один и тот же тип элементов.
  4. Элементы в someContainer могут быть проверены оператором неравенства (!=), чтобы увдиеть, если они отличаются друг от друга.
Третье и четвёртое требования объединяются, чтобы обозначать тот факт, что anotherContainer так же может быть проверено оператором !=, так как они имеют тот же тип, что и элементы в someContainer.

Эти требования позволяют функции allItemsMatch(_:_:) сравнить эти два контейнера, даже если это два разных типа контейнера.

Функция allItemsMatch(_:_:) начинается с проверки того, что оба контейнера имеют одинаковое количество элементов. Если они содержат разное количество элементов, то нельзя сказать, что они совпадают и функция возвращает false.

После выполнения этой проверки функция проходит через все элементы someContainer c помощью цикла for-in и полуоткрытого оператора интервала (..<). Для каждого элемента функция проверяет, отличается ли элемент из someContainer от соответствующего элемента в anotherContainer. Если два объекта неравны, то значит, что два контейнера не совпадают, а функция возвращает (false).

Если цикл завершает свою работу без нахождений несовпадений, то два контейнера совпадают, и функция возвращает true.
@{18.2\4\3}
Здесь представлено, как выглядит функция allItemsMatch(_:_:) в действии:
var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")

var arrayOfStrings = ["uno", "dos", "tres"]

if allItemsMatch(stackOfStrings, arrayOfStrings) {
    print("All items match.")
} else {
    print("Not all items match.")
}
// Выведет "All items match."
Пример выше создаёт объект типа Stack для хранения значений типа String и отправляет три строки на стэк. Пример также создаёт объект Array, инициализированный либералом массива, содержащим те же самые строки, что и стэк. Хотя даже стэк и массив имеют различные типы, они оба удовлетворяют протоколу Container и оба содержат значения одного и того же типа. Следовательно Вы можете вызвать функцию allItemsMatch(_:_:) с этими двумя контейнерами в качестве аргументов. В примере выше функция allItemsMatch(_:_:) корректно сообщает, что все элементы в двух контейнерах совпадают.
Расширения с блоками шаблонов where
Вы также можете использовать блок шаблона where в качестве части расширения. Пример ниже расширяет шаблонную структуру Stack из предыдущих примеров для добавления метода isTop(_:).
@{18.2\4\4}
extension Stack where Element: Equatable {
    func isTop(_ item: Element) -> Bool {
        guard let topItem = items.last else {
            return false
        }
        return topItem == item
    }
}
Новый метод isTop(_:) сперва проверяет, что стэк не пуст, а затем сравнивает заданный элемент с вершиной стэка. Если Вы попытаясь сделать это без блока where, то Вы получите проблемы. Реализация isTop(_:) использует оператор ==, но определение Stack не требует от его элементов быть сравниваемыми на равенство, так что использование оператор == выдаст в результате ошибку времени компиляции. Использование блока where позволяет Вам добавить требование на расширение, так что расширение добавит метод isTop(_:), только если элементы в стэке/ сравнимы на равенство.
@{18.2\4\5}
Здесь показано, как метод isTop(_:) ведёт себя в действии:
if stackOfStrings.isTop("tres") {
    print("Top element is tres.")
} else {
    print("Top element is something else.")
}
// Выведет "Top element is tres."
@{18.2\4\6}
Если Вы попытаетесь вызвать метод isTop(_:) на стэке, чьи элементы не сравнимы на равенство, то Вы получите ошибку времени компиляции.
struct NotEquatable { }
var notEquatableStack = Stack()
let notEquatableValue = NotEquatable()
notEquatableStack.push(notEquatableValue)
notEquatableStack.isTop(notEquatableValue)  // Ошибка
@{18.2\4\7}
Вы можете использовать блок where с расширениями протоколов. Пример ниже расширяет протокол Container из предыдущих примеров для добавления в него метода startsWith(_:).
extension Container where Item: Equatable {
    func startsWith(_ item: Item) -> Bool {
        return count >= 1 && self[0] == item
    }
}
@{18.2\4\8}
Метод startsWith(_:) сперва убеждается, что контейнер имеет по крайней мере один элемент, а затем проверяет, совпадает ли первый элемент в контейнере с данным элементом. Этот новый метод startsWith(_:) может быть использован с любым типом, который удовлетворяет протоколу Container, включая стэки и массивы, используемые выше, если элементы контейнера сравнимы на равенство.
if [9, 9, 9].startsWith(42) {
    print("Starts with 42.")
} else {
    print("Starts with something else.")
}
// Выведет "Starts with something else."
@{18.2\4\9}
Блок where в примере выше требует от Item удовлетворять протоколу, но Вы также можете написать блоки where, которые будут требовать от Item быть конкретным типом. Например:
extension Container where Item == Double {
    func average() -> Double {
        var sum = 0.0
        for index in 0..< count {
            sum += self[index]
        }
        return sum / Double(count)
    }
}
print([1260.0, 1200.0, 98.6, 37.0].average())
// Выведет "648.9"
Этот пример добавляет метод average() контейнерам, чей тип Item равен Double. Он проходит через элементы в контейнере для сложения их, а затем делит сумму контейнера для вычисления среднего значения. Он явно приводит сумму из Int к Double, чтобы иметь возможность выполнить деление с плавающей точкой.

Вы можете включить несколько требований в один блок where/ в расширении, как вы делали для блока where, который Вы писали ранее. Разделите каждое требование в списке с помощью запятых.
Ассоциированные типы с блоком шаблона where
Вы можете включить блок where с ассоциированным типом. Например, предположим, что Вы хотите сделать версию Container, которая включает итератор как протокол Sequence, используемый в стандартной библиотеке. Вот, как Вы могли бы его записать:
@{18.2\5\1}
Блок where на Iterator требует, чтобы итератор мог проходить через элементы одного типа, что и элементы контейнера, вне зависимости от типа итератора. Функция makeIterator() предоставляет доступ к итератору контейнера.
protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }

    associatedtype Iterator: IteratorProtocol where Iterator.Element == Item
    func makeIterator() -> Iterator
}
@{18.2\5\2}
Для протокола, который наследуется от другого протокола, Вы можете добавить ограничение на наследуемый ассоциированный тип, включив блок where в объявление протокола. Например, следующий код объявляет протокол ComparableContainer, который требует от Item соответствовать протоколу Comprarable:
protocol ComparableContainer: Container where Item: Comparable { }
Шаблонные сабскрипты
Сабскрипты могут быть шаблонными, и они могут включать блоки where. Вы пишите замещаемое имя типа внутри угловых скобок после слова subscript, и Вы можете написать блок where сразу перед открывающей фигурной скобкой тела сабскрипта. Например:
@{18.2\5\3}
extension Container {
    subscript<Indices: Sequence>(indices: Indices) -> [Item]
    where Indices.Iterator.Element == Int {
    var result = [Item]()
    for index in indices {
        result.append(self[index])
    }
        return result
    }
}
Расширения протокола Container добавляет сабскрипт, который принимает последовательность индексов и возвращает массив, содержащий элементы на этих позициях. Шаблонный сабскрипт ограничен следующим образом:
  • Шаблонный параметр Indices в угловых скобках имеет тип, удовлетворяющий протоколу Sequence из стандартной библиотеки.
  • Сабскрипт принимает единственный параметр indices, который имеет тип Indices.
  • Блок where требует от итератора последовательности проходить чете элементы типа Int. Это гарантирует, что индексы в последовательности имеют тот же тип, что индексы, используемые в контейнере.
  • Взятые вместе, эти ограничения означают, что значение переданное в качестве параметра indices есть последовательность целых чисел.
Ярлыки типов
Псевдонимы типов определяют альтернативное имя для существующего типа. Вы определяете псевдонимы типов с помощью ключевого слова typealias.

Псевдонимы типов применимы, когда Вы хотите сослаться на существующий тип по имени, более подходящему по контексту, также как с работой с данными определённой величины из внешнего источника:
@{18.2\6\1}
typealias AudioSample = UInt16
@{18.2\6\2}
Как только Вы определите псевдоним типа, Вы можете использовать его везде, где Вы могли бы использовать оригинальное название:
var maxAmplitudeFound = AudioSample.max
Здесь, AudioSample определён как псевдоним для UInt16. Потому что это псевдоним, то вызов AudioSample.min на самом деле вызывает UInt16.min, который предоставляет изначальное значение 0 для maxAmplitudeFound.