Link Search Menu Expand Document

Узагальнення

Узагальнений код дозволяє писати гнучкі функції та типи, котрі можна повторно використовувати, що працюють із будь-якими типами відповідно до ваших вимог. Таким способом можна писати код, що уникає повторень, та виражає намір автора у зрозумілій та абстрагованій манері.

Узагальнення є однією з найбільш потужних можливостей мови Swift, і велика частина стандартної бібліотеки Swift побудована за допомогою узагальненого коду. Фактично, ми користувались узагальненнями всюди у Керівництві з мови, навіть якщо ми не усвідомлювали цього. Наприклад, типи Array та Dictionary у Swift є узагальненими колекціями. Можна створити масив, що зберігає значення типу Int, або типу String, або будь-якого іншого типу, що можна створити у Swift. Аналогічно, можна створити словник, що зберігає значення будь-якого вказаного типу, і немає жодних обмежень щодо типу цих значень.

Проблема, котру вирішують узагальнення

Ось стандартна, не узагальнена функція, що називається swapTwoInts(_:_:), та міняє місцями два значення типу Int:

func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA = a
    a = b
    b = temporaryA
}

Ця функція для обміну значень a та b використовує двонаправлені параметри, що описані у підрозділі Двонаправлені параметри.

Функція swapTwoInts(_:_:) підставляє початкове значення b у змінну a, і початкове значення a у змінну b. Можна викликати цю функцію для обміну двох значень типу Int:

var someInt = 3
var anotherInt = 107
swapTwoInts(&someInt, &anotherInt)
print("someInt тепер дорівнює \(someInt), а anotherInt тепер дорівнює \(anotherInt)")
// Надрукує "someInt тепер дорівнює 107, а anotherInt тепер дорівнює 3"

Функція swapTwoInts(_:_:) є корисною, але її можна застосувати лише до пари значень типу Int. Якщо потрібно обміняти значеннями дві змінні типу String чи Double, потрібно написати ще функцій, наприклад swapTwoStrings(_:_:) та swapTwoDoubles(_:_:):

func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let temporaryA = a
    a = b
    b = temporaryA
}

func swapTwoDoubles(_ a: inout Double, _ b: inout Double) {
    let temporaryA = a
    a = b
    b = temporaryA
}

Слід помітити, що тіла функцій swapTwoInts(_:_:), swapTwoStrings(_:_:), та swapTwoDoubles(_:_:) повністю співпадають. Єдиною відмінністю є тип значень, що приймають ці функції (Int, String, та Double).

Було б ефективніше та набагато більш гнучко написати одну функцію, що обмінює значеннями два значення будь-якого типу. Узагальнений код дозволяє написати таку функцію. (Узагальнена версія цих трьох функцій оголошується далі).

Примітка

У всіх цих трьох функціях, тип у змінних a та b має бути однаковим. Якщо тип у a та b не співпадає, неможливо обміняти їх значеннями. Swift є типобезпечною мовою, і тому не дозволяє (наприклад) обміняти значеннями змінні типу String та Double. Спроба зробити це призводить до помилки часу компіляції.

Узагальнені функції

Узагальнені функції можуть працювати з будь-яким типом. Ось приклад узагальненої версії функції swapTwoInts(_:_:) з прикладу вище, котра називається swapTwoValues(_:_:):

func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}

Тіло функції swapTwoValues(_:_:) є ідентичним до тіла функції swapTwoInts(_:_:). Однак, перший рядок функції swapTwoValues(_:_:) трохи відрізняється від swapTwoInts(_:_:). Ось перші два рядки в порівнянні:

func swapTwoInts(_ a: inout Int, _ b: inout Int)
func swapTwoValues<T>(_ a: inout T, _ b: inout T)

Узагальнена функція використовує замісник назви типу (що в даному випадку називається T) замість фактичної назви типу (на кшталт Int, String, чи Double). Замісник назви типу нічого не каже про то, чим може бути T, але він каже, що a та b повинні мати однаковий тип T, яким би він не був. Фактичний тип для використання замість T встановлюється в момент виклику функції swapTwoValues(_:_:).

Різниця між узагальненою та не узагальненою функцією полягає також у тому, що після назви узагальненої функції (swapTwoValues(_:_:)) йде замісник назви типу (T) у фігурних дужках (<T>). Фігурні дужки підказують компілятору Swift, що T – це замісник назви типу у визначенні функції swapTwoValues(_:_:). Оскільки T – це замісник назви типу, компілятор Swift не шукатиме фактичний тип, що називається T.

Функція swapTwoValues(_:_:) може тепер викликатись так само, як і swapTwoInts(_:_:), але їй можна передавати пару значень будь-якого типу, головне, щоб це були значення одного й того ж типу. При кожному виклику swapTwoValues(_:_:), тип, який слід використовувати замість T, визначається з типів значень, що передаються до функції.

У двох прикладах нижче, T визначається як Int та String відповідно:

var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt)
// someInt тепер дорівнює 107, а anotherInt тепер дорівнює 3

var someString = "hello"
var anotherString = "world"
swapTwoValues(&someString, &anotherString)
// someString тепер дорівнює "world", а anotherString тепер дорівнює "hello"

Примітка

Визначена вище функція swapTwoValues(_:_:) є аналогом узагальненої функції swap(::), що є частиною стандартної бібліотеки Swift, та є автоматично доступною для використання у ваших додатках. Якщо вам потрібна поведінка функції swapTwoValues(_:_:) у вашому коді, слід використовувати існуючу функцію swap(::) замість того, щоб реалізовувати її самотужки.

Параметри типів

У прикладі з swapTwoValues(_:_:), замісник назви типу T є прикладом параметра типу. Параметри типів визначають та іменують замісник типу, і записуються одразу після назви функції, всередині пари кутових дужок (наприклад, <T>).

Як тільки вказано параметр типу, його можна використовувати для визначення типу параметрів функції (наприклад, типи параметрів a та b функції swapTwoValues(_:_:)), або типу, що повертається функцією, або як анотацію типу всередині тіла функції. В кожному випадку, тип параметра замінюється фактичним типом при кожному виклику функції. (У прикладі з swapTwoValues(_:_:) вище, T замінювався на Int у першому виклику функції, та на String у другому виклику функції).

Функція може мати декілька параметрів типів, їх так само записують у кутових дужках, розділюючи комами.

Іменування параметрів типів

У більшості випадків, параметри типів мають змістовні назви, як наприклад Key (ключ) та Value (значення) у Dictionary<Key, Value>, котрі вказують читачу на зв’язок між параметром типу та узагальненим типом або функцією, в якому він використовується. Однак, у випадках, коли між ними нема змістовного зв’язку, традиційно типи параметрів однією великою літерою, зазвичай T, U, чи V, як T у функції swapTwoValues(_:_:) вище.

Примітка

Слід завжди давати параметрам типів назви, що пишуться ВерхнімВерблюжимРегістром (наприклад, T або MyTypeParameter), щоб позначити, що це замісник типу, а не значення.

Узагальнені типи

Окрім узагальнених функцій, Swift дає можливість створювати власні узагальнені типи. Ними можуть бути власні класи, структури та перечислення, що можуть працювати з будь-яким типом, аналогічно до вбудованих Array та Dictionary.

Цей підрозділ покаже, як написати власну узагальнену колекцію на ім’я Stack. Стек - це впорядкована множина значень, аналогічна до масиву, але із більш обмеженим набором операцій, ніж у масивів у Swift. Масиви у Swift дозволяють вставляти чи видаляти елементи в будь-якій позиції. Стек дозволяють новим елементам додаватись лише в кінець колекції (про що кажуть: “заштовхнути елемент у стек”, або push). Стек також дозволяє елементам видалятись, але лише з кінця колекції (про що кажуть: “виштовхнути елемент зі стека”, або pop).

Примітка

Концепція стеку використовується класом UINavigationController для моделювання в’ю-контоллерів у ієрархії навігації в додатках на iOS. Щоб додати в’ю-контроллер до навігаційного стеку, слід викликати метод pushViewController(_:animated:) класу UINavigationController, а щоб прибрати (тобто виштовхнути) в’ю-контроллер з навігаційного стеку – метод popViewControllerAnimated(_:). Стек є корисною моделлю колекції в тих випадках, де для керування колекцією потрібне строге правило “останнім прийшов — першим пішов” (або LIFO, від англійсього “last in, first out”).

Ілюстрація нижче демонструє поведінку стека і його операції push та pop:

  1. У стеку зберігаються три значення.
  2. До стека заштовхується четверте значення.
  3. Стек тепер тримає чотири значення, при цьому останнє лежить на його вершині.
  4. Зі стека виштовхується значення.
  5. Після виштовхування значення, стек знову містить три початкові значення.

Ось як можна записати не узагальнену версію стеку, в даному випадку для стека значень типу Int:

struct IntStack {
    var items = [Int]()
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }
}

У даній структурі для зберігання значень у стеку використовується масив значень типу Int, що зберігається у властивості на ім’я items. Цей стек має два методи, push() та pop(), що відповідно заштовхують та виштовхують елементи стеку. Ці методи позначені модифікатором mutating, оскільки їм потрібно змінити масив, що зберігається у даній структурі.

Однак тип IntStack може використовуватись лише для зберігання значень типу Int. Було б набагато більш ефективно створити узагальнений клас Stack, котрий би керував стеком значень будь-якого типу.

Ось узагальнена версія того ж коду:

struct Stack<Element> {
    var items = [Element]()
    mutating func push(_ item: Element) {
        items.append(item)
    }
    mutating func pop() -> Element {
        return items.removeLast()
    }
}

Слід помітити, що узагальнена версія Stack є практично такою ж, як і не узагальнена версія, єдиною відмінністю є параметр типу на ім’я Element замість фактичного типу Int. Параметр типу записується всередині пари кутових дужок (<Element>) одразу після назви структури.

Element визначає замісник назви типу, що буде надана пізніше. До цього майбутнього типу далі можна звертатись як Element будь-де у визначенні структури. В даному випадку, Element використовується як замісник у трьох місцях:

  • Для створення властивості items, котра ініціалізується порожнім масивом значень типу Element.
  • Щоб вказати, що метод push(_:) має єдиний параметр на ім’я item, котрий повинен мати тип Element.
  • Щоб вказати, що метод pop() повертає значення типу Element.

Оскільки Stack є узагальненим типом, його можна використовувати для створення стеку будь-якого типу Swift, аналогічно до Array та Dictionary.

Щоб створити новий екземпляр Stack, слід записати тип значень, що зберігатиметься у стеку, у фігурних дужках. Наприклад, щоб створити стек рядків, слід написати Stack<String>():

var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")
stackOfStrings.push("cuatro")
// стек містить 4 рядки

Ось як виглядає стек stackOfStrings після заштовхування в нього чотирьох значень:

Виштовхування значень зі стека видаляє та повертає значення на вершині стеку, "cuatro":

let fromTheTop = stackOfStrings.pop()
// fromTheTop тепер дорівнює "cuatro", а стек тепер містить 3 рядки

Ось як виглядає стек після виштовхування верхнього значення:

Розширення узагальнених типів

При розширенні узагальненого типу не потрібно вказувати список параметрів типів у оголошенні розширення. При цьому список параметрів типів з оригінального оголошення типу є доступним для тіла розширення, і оригінальні назви параметрів типів можна використовувати для посилання на параметри в оригінальному оголошенні.

Наступний приклад розширює узагальнений тип Stack, додаючи до нього властивість тільки для читання, що обчислюється і називається topItem, котра повертає елемент на вершині стеку, без виштовхування цього елементу зі стека:

extension Stack {
    var topItem: Element? {
        return items.isEmpty ? nil : items[items.count - 1]
    }
}

Властивість topItem повертає опціональне значення типу Element. Якщо стер порожній, topItem поверне nil; якщо стек не порожній, topItem поверне останній елемент у масиві items.

Варто помітити, що розширення не оголошує список параметрів типів. При цьому назва існуючого параметра типу Element використовується всередині розширення для позначення опціонального типу властивості topItem.

Властивість, що обчислюється, topItem тепер може бути використаною з будь-яким екземпляром Stack для отримання елемента на вершині стеку без його видалення:

if let topItem = stackOfStrings.topItem {
    print("Елементом на вершині стеку є '\(topItem)'.")
}
// Надрукує "Елементом на вершині стеку є 'tres'."

Розширення узагальненого типу можуть також включати вимоги, котрі мають задовольняти типи, що отримають нову функціональність. Це детальніше описано далі, в підрозділі Розширення з інструкцією узагальнення Where.

Обмеження типів

Функція swapTwoValues(_:_:) та тип Stack можуть працювати із будь-яким типом. Однак, часто буває потрібно накласти певні обмеження на типи, які можуть використовуватись в узагальнених функціях та типах. Обмеження типів вказують, що параметр типу повинен наслідуватись від певного класу, або підпорядковуватись певному протоколу, чи композиції протоколів.

Наприклад, тип Dictionary у Swift вводить обмеження на типи, що можуть використовуватись як ключі у словнику. Як описано у підрозділі Словники, тип ключа у словнику має підпорядковуватись протоколу Hashable, тобто вміти обчислити свій хеш. Іншими словами, ключ повинен надавати своє однозначне представлення. Словнику потрібно знаходити хеш ключа для того, щоб перевіряти, чи містить він для цього ключа значення. Без цього обмеження, словник не мав би змоги з’ясувати, потрібно вставляти нове значення чи заміняти існуючи, а також не мав би змоги з’ясувати, чи міститься у ньому значення за заданим ключем.

Дана вимога ставиться за допомогою обмеження типу ключа для Dictionary, котра визначає, що ключ має підпорядковуватись протоколу Hashable, спеціальному протоколу зі стандартної бібліотеки Swift. Всі базові типи Swift (такі, як String, Int, Double, та Bool) є за замовчанням підпорядкованими цьому протоколу.

Створюючи власні узагальнені типи, можна ставити власні обмеження типів, і ці обмеження містять у собі велику силу узагальненого програмування. Абстрактні концепції на кшталт Hashable характеризують типи з точки зору їх концептуальних характеристик, допомагаючи уникати використання конкретних типів.

Синтаксис обмежень типів

Обмеження типів записуються шляхом вказування назви класу чи протоколу у списку параметрів типів, після назви параметру типу, відділяючись від нього комою. Базовий синтаксис обмеження типів узагальненої функції показано нижче (синтаксис для узагальнених функцій та типів однаковий):

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // тут йде тіло функції
}

Гіпотетична функція вище має два параметри типів. Перший параметр типу, T, має обмеження типу, що вимагає T бути класом-нащадком класу SomeClass. Другий параметр, U, має обмеження типу, що вимагає U бути підпорядкованим протоколу SomeProtocol.

Обмеження типів у дії

Ось приклад не узагальненої функції на ім’я findIndex(ofString:in:), котра шукає заданий рядок у заданому масиві рядків. Функція findIndex(ofString:in:) повертає опціональне цілочисельне значення, котре виражає індекс першого рядка в масиві, що співпав із заданим, або nil, якщо не співпав жоден:

func findIndex(ofString valueToFind: String, in array: [String]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

Функцію findIndex(ofString:in:) можна використовувати для знаходження рядку у масиві рядків:

let strings = ["кіт", "пес", "лама", "папуга", "черепаха"]
if let foundIndex = findIndex(ofString: "лама", in: strings) {
    print("Рядок 'лама' має індекс \(foundIndex)")
}
// Надрукує "Рядок 'лама' має індекс 2"

Однак, принцип знаходження індексу значення в масиві може стати в пригоді не лише для рядків. Можна написати аналогічну функціональність у вигляді узагальненої функції, замінюючи згадки типу String на параметр типу T.

Ось як могла б виглядати узагальнена версія функції 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, і компілятор вкаже на це у повідомленні про помилку.

Однак, не все втрачено. У стандартній бібліотеці Swift є протокол, що має назву Equatable, котрий вимагає в підпорядкованого типу реалізовувати оператори рівності (==) та нерівності (!=) для порівняння двох значень даного типу. Всі стандартні типи Swift автоматично підпорядковані протоколу Equatable.

Будь-який тип, підпорядкований протоколу Equatable, можна безпечно використовувати у функції findIndex(of:in:), оскільки він гарантовано підтримує оператор рівності. Для вираження цього факту, вимога підпорядковуватись протоколу Equatable записується як обмеження типу T у визначенні функції:

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.

Функція findIndex(of:in:) тепер успішно компілюється та може використовуватись із будь-яким підпорядкованим протоколу Equatable типом, наприклад, Double чи String:

let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
// doubleIndex є опціональним Int без значення, бо значення 9.3 відсутнє в масиві
let stringIndex = findIndex(of: "Антоніна", in: ["Михайло", "Максим", "Антоніна"])
// stringIndex є опціональним Int зі значенням 2

Асоційовані типи

При визначенні протоколу, іноді буває зручно оголошувати один чи декілька асоційованих типів у оголошенні протоколу. Асоційовані типи виступають замісником назви типу, що може використовуватись у протоколі. Фактичний тип заміщує асоційований тип в момент підпорядкування протоколу якимось типом. Асоційовані типи оголошуються за допомогою ключового слова associatedtype.

Асоційовані типи в дії

Ось приклад протоколу, що має назву Container та оголошує асоційований тип на ім’я Item:

protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}

Протокол Container визначає три необхідні можливості, що повинен надавати будь-який контейнер:

  • Додавати нові елементи в контейнер за допомогою методу append(_:).
  • Дізнаватись про кількість елементів у контейнері за допомогою цілочисельної властивості для читання count
  • Отримувати елемент з контейнера за цілочисельним індексом.

Даний протокол не визначає, як повинні зберігатися елементи в контейнері, чи якого типу вони повинні бути. Протокол визначає лише три кавалки функціональності, котрі повинний надати тип для того, щоб вважатись контейнером. Підпорядкований тип може надавати й додаткову функціональність, однак при цьому він повинен задовольняти дані три вимоги.

Будь-який підпорядкований протоколові Container тип повинен вказати тип значення, що в ньому зберігатимуться. Зокрема, він має гарантувати, що додати до контейнера можна лише елементи правильного типу, і що дістати з контейнера за індексом можна лише елементи цього ж типу.

Щоб визначити ці вимоги, протокол Container повинен посилатись на тип елементів, що зберігатимуться в контейнері, не знаючи якого конкретно вони будуть типу. Протокол Container повинен вказати, що будь-яке значення, що передається до методу append(_:) повинно мати той же тип, що зберігається у контейнері, і що значення, котре повертається індексом контейнера теж має той же тип, що зберігається у контейнері.

Щоб досягти цього, у протоколі Container оголошується асоційований тип на ім’я Item, що записується як associatedtype Item. Протокол не визначає, чим є Item: право визначати цю інформацію залишається за підпорядкованим типом. Але разом з тим, псевдонім Item надає спосіб посилатись на тип елементів у протоколі Container: визначати тип, що передається до методу append(_:) та повертається індексом, і таким чином змушувати будь-який Container мати очікувану поведінку.

Ось приклад не узагальненого типу IntStack з підрозділу Узагальнені типи вище, котрий було підпорядковано протоколу Container:

struct IntStack: Container {
    // початкова реалізація IntStack
    var items = [Int]()
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }
    // підпорядкування протоколу Container
    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, тип Int грає роль типу Item. Визначення псевдоніму typealias Item = Int перетворює абстрактний тип Item на конкретний тип Int для цієї реалізації протоколу Container.

Завдяки визначенню типів у Swift, фактично не потрібно визначати конкретний Item типу Int в оголошенні IntStack. Оскільки IntStack підпорядковується до усіх вимог протоколу Container, Swift може визначати відповідний тип для використання у ролі Item, просто поглянувши на тип параметра методу append(_:), або тип, що повертається індексом. Справді, якщо з коду видалити рядок із псевдонімом типу typealias Item = Int, він все одно працюватиме, оскільки зрозуміло, який тип слід використовувати в якості Item.

Узагальнений тип Stack можна також підпорядкувати протоколу Container:

struct Stack<Element>: Container {
    // початкова реалізація Stack<Element>
    var items = [Element]()
    mutating func push(_ item: Element) {
        items.append(item)
    }
    mutating func pop() -> Element {
        return items.removeLast()
    }
    // підпорядкування протоколу Container
    mutating func append(_ item: Element) {
        self.push(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Element {
        return items[i]
    }
}

Цього разу, як тип параметру методу append(_:), та тип, що повертається індексом, використовується тип Element. В цьому випадку визначення типів у Swift знову допомагає встановити, що в якості типу Item слід використовувати тип Element.

Розширення існуючого типу для визначення асоційованого типу

Існуючі типи можна розширювати для підпорядкування їх певному протоколу, як це описано у підрозділі Підпорядкування протоколу за допомогою розширення. Це стосується також і протоколів з асоційованими типами.

Тип Array у Swift вже має метод append(_:), властивість count та цілочисельний індекс, що повертає його елементи. Ці три можливості співпадають з вимогами протоколу Container. Це означає, що можна розширити масив, підпорядковуючи його протоколові Container, просто оголосивши, що Array є підпорядкований протоколу. Це можна зробити за допомогою порожнього розширення, що описано у підрозділі Оголошення підпорядкування протоколу за допомогою розширення:

extension Array: Container {}

Існуючі метод append(_:) та цілочисельний індекс дозволяють Swift визначити правильний тип для використання в якості асоційованого типу Item, так само, як і у випадку з узагальненим типом Stack вище. Після визначення цього розширення, будь-який масив можна використовувати як Container.

Додавання обмежень на асоційований тип

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

У протоколах обмеження типу на асоційовані типи додають для того, щоб вимагати у підпорядкованих типів задовольняти умови цих обмежень. Наприклад, у наступному прикладі оголошено версію протоколу Container, що вимагає, щоб елементи в контейнері підпорядковувались протоколу Equatable.

protocol Container {
    associatedtype Item: Equatable
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}

Щоб підпорядковуватись даній версії протоколу Container, тип Item контейнера повинен підпорядковуватись протоколу Equatable.

Використання протоколів з обмеженнями асоційованих типів

Протокол може з’являтись як частина власних обмежень. Наприклад, ось протокол, що удосконалює протокол Container, додаючи до вимог метод suffix(_:). Метод suffix(_:) повертає задану кількість елементів з кінця контейнера, зберігаючи їх в екземплярі типу Suffix.

protocol SuffixableContainer: Container {
    associatedtype Suffix: SuffixableContainer where Suffix.Item == Item
    func suffix(_ size: Int) -> Suffix
}

У цьому протоколі, Suffix є асоційованим типом, як і тип Item у протоколі Container у прикладах вище. Асоційований тип Suffix має два обмеження: він має підпорядковуватись протоколу SuffixableContainer (протоколу, що в даний момент оголошується), та його тип Item повинен співпадати із типом Item даного контейнера. Обмеження на Item задається за допомогою інструкції узагальнення where, що описана далі у підрозділі Асоційовані типи з інструкцією узагальнення Where.

Ось розширення типу Stack із підрозділу Узагальнені типи, котре підпорядковує його до протоколу SuffixableContainer:

extension Stack: SuffixableContainer {
    func suffix(_ size: Int) -> Stack {
        var result = Stack()
        for index in (count-size)..<count {
            result.append(self[index])
        }
        return result
    }
    // Тип Suffix визначено як Stack.
}
var stackOfInts = Stack<Int>()
stackOfInts.append(10)
stackOfInts.append(20)
stackOfInts.append(30)
let suffix = stackOfInts.suffix(2)
// suffix містить елементи 20 та 30

У прикладі вище, асоційованим типом Suffix для структури Stack є також Stack, таким чином операція suffix на типі Stack повертає інший Stack. Однак, підпорядкований протоколу SuffixableContainer тип може мати Suffix, що не співпадає з цим же типом: іншими словами, операція suffix може повертати інший тип. Наприклад, ось розширення не узагальненого типу IntStack, що підпорядковує його протоколові SuffixableContainer, використовуючи тип Stack<Int> як тип Suffix замість типу IntStack:

extension IntStack: SuffixableContainer {
    func suffix(_ size: Int) -> Stack<Int> {
        var result = Stack<Int>()
        for index in (count-size)..<count {
            result.append(self[index])
        }
        return result
    }
    // Тип Suffix визначено як Stack<Int>.
}

Інструкція узагальнення Where

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

Також буває потрібно визначати вимоги до асоційованих типів. Це можна зробити за допомогою інструкції узагальнення where. Інструкція узагальнення where дозволяє вимагати, щоб асоційований тип підпорядковувався певному протоколу, або щоб певні параметри типів та асоційовані типи були однаковими. Інструкція узагальнення where починається з ключового слова where, після чого йдуть обмеження на асоційовані типи, чи відношення рівності між типами та асоційованими типами. Інструкції узагальнення where записуються прямо перед фігурною дужкою, з якої починається тіло типу чи функції.

У прикладі нижче визначено узагальнену функцію, що називається allItemsMatch, котра перевіряє, чи співпадають елементи двох контейнерів, тобто чи містять два контейнери однакові елементи в одному й тому ж порядку. Дана функція повертає булеве значення true, якщо всі елементи співпадають, та false, якщо ні.

Два контейнери, що перевіряються, не обов’язково повинні бути одного типу (хоч вони й можуть буту одного типу), але вони повинні містити елементи одного типу. Цю вимогу можна виразити за допомогою комбінації обмеження типів та інструкції узагальнення where:

func allItemsMatch<C1: Container, C2: Container>
    (_ someContainer: C1, _ anotherContainer: C2) -> Bool
    where C1.Item == C2.Item, C1.Item: Equatable {

        // Перевіряємо, що обидва контейнери містять однакову кількість елементів.
        if someContainer.count != anotherContainer.count {
            return false
        }

        // Перевіряємо пари елементів, щоб переконатись, що вони еквівалентні.
        for i in 0..<someContainer.count {
            if someContainer[i] != anotherContainer[i] {
                return false
             }
        }

        // Якщо всі елементи співпадають, повертаємо true.
        return true
}

Ця функція приймає два аргументи, що називаються someContainer та anotherContainer. Аргумент someContainer має тип C1, а аргумент anotherContainerC2. Як C1, так і C2 є параметрами типу, що є типами-контейнерами, і конкретні їх типи будуть визначені під час виклику функції.

До двох параметрів типу даної функції виставляються наступні вимоги:

  • C1 має підпорядковуватись протоколу Container (записується як C1: Container).
  • C2 також має підпорядковуватись протоколу Container (записується як C2: Container).
  • Тип Item для C1 має співпадати із типом Item для C2 (записується як C1.Item == C2.Item).
  • Тип Item для C1 має підпорядковуватись протоколу Equatable (записується як C1.Item: Equatable).

Перша та друга вимоги оголошуються прямо у списку параметрів типу функції, а третя та четверта вимоги оголошуються в інструкції узагальнення where даної функції.

Ці вимоги означають, що:

  • someContainer є контейнером типу C1.
  • anotherContainer є контейнером типу C2.
  • someContainer та anotherContainer містять елементи одного типу.
  • Елементи у someContainer можна перевірити на нерівність за допомогою оператору нерівності !=, щоб дізнатись, чи відрізняються вони один від одного.

Поєднання третьої та четвертої вимог означає, що елементи у anotherContainer також можна перевірити на нерівність за допомогою оператору !=, оскільки вони мають точно такий же тип, що й елементи у someContainer.

Ці вимоги дозволяють функції allItemsMatch(_:_:) порівнювати два контейнери, навіть якщо вони мають різний тип.

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

Після цієї перевірки, функція ітерує усі елементи у контейнері someContainer за допомогою циклу for-in та напіввідкритого оператора діапазону (..<). Для кожного елемента, функція перевіряє, чи дорівнює елемент із someContainer його відповіднику із anotherContainer. Якщо два елементи не співпадають, тоді й два контейнери не співпадають, і функція повертає false.

Якщо цикл закінчується, не знайшовши неспівпадіння, тоді обидва контейнери співпадають, і функція повертає true.

Ось як функція allItemsMatch(_:_:) виглядає у дії:

var stackOfStrings = Stack<String>()
stackOfStrings.push("bir")
stackOfStrings.push("eki")
stackOfStrings.push("üç")

var arrayOfStrings = ["bir", "eki", "üç"]

if allItemsMatch(stackOfStrings, arrayOfStrings) {
    print("Всі елементи співпадають.")
} else {
    print("Не всі елементи співпадають.")
}
// Надрукує "Всі елементи співпадають."

У прикладі вище створено екземпляр Stack, що зберігає значення типу String, і в нього заштовхнуто три рядки. Далі у прикладі створено масив, що ініціалізовано літералом масиву, котрий містить ці ж само три рядки, що було заштовхнуто до стека. Хоч Stack та Array - різні типи, вони обидва підпорядковуються протоколу Container, і обидва містять значення одного й того ж типу. Тому можна викликати функцію allItemsMatch(_:_:) із цими двома контейнерами в якості аргументів. У прикладі вище, функція allItemsMatch(_:_:) коректно визначає, що всі елементи у цих двох контейнерах співпадають.

Розширення з інструкцією узагальнення Where

Інструкцію узагальнення where можна також використовувати як частину розширення. У прикладі нижче узагальнену структуру Stack з попередніх прикладів розширено, додаючи у неї метод isTop(_:).

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(_:) лише для тоді, коли елементи у Stack підпорядковані протоколу Equatable.

Ось як виглядає у дії метод isTop(_:):

if stackOfStrings.isTop("üç") {
    print("Елементом на горі є üç.")
} else {
    print("Елементом на горі є щось інше.")
}
// Надрукує "Елементом на горі є üç".

Якщо спробувати викликати метод isTop(_:) на стеку, чиї елементи не є порівнюваними, отримаємо помилку компіляції:

struct NotEquatable { }
var notEquatableStack = Stack<NotEquatable>()
let notEquatableValue = NotEquatable()
notEquatableStack.push(notEquatableValue)
notEquatableStack.isTop(notEquatableValue)  // Помилка

Інструкцію узагальнення where можна також використовувати у розширеннях протоколів. У прикладі нижче протокол Container з попередніх прикладів розширено методом startsWith(_:).

extension Container where Item: Equatable {
    func startsWith(_ item: Item) -> Bool {
        return count >= 1 && self[0] == item
    }
}

Метод startsWith(_:) спершу упевнюється в тому, що контейнер містить хоча б один елемент, і потім перевіряє, чи співпадає передане значення з першим елементом у контейнері. Цей новий метод startsWith(_:) можна тепер використовувати із будь-яким підпорядкованим протоколу Container типом, включно зі стеками та масивами, що використовувались вище. Єдиною вимогою для цього є підпорядкування елементів контейнера протоколу Equatable.

if [9, 9, 9].startsWith(42) {
    print("Починається із 42.")
} else {
    print("Починається із чогось іще.")
}
// Надрукує "Починається із чогось іще."

Інструкція узагальнення 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"

У даному прикладі до усіх контейнерів, чиїм типом Item є Double, додається метод average(), котрий підраховує середнє арифметичне значення усіх елементів контейнера. Для цього цей метод ітерує усі елементи у контейнері, підраховуючи їх суму, і ділить цю суму на кількість елементів у контейнері. У ньому значення count явно перетворюється із Int на Double для уможливлення ділення.

Для розширень також можна вказувати декілька вимог в одній інструкції узагальнення where, як це можна робити у будь-якій інструкції узагальнення where. Можна вимога у списку повинна відділятися комою.

Контекстуальна інструкція узагальнення Where

Інструкцію узагальнення where можна використовувати як частину оголошення, що не має власних обмежень типів, коли ви уже працюєте у контексті узагальненого типу. Наприклад, інструкцію узагальнення where можна вказати на індексі узагальненого типу, або на методі у розширенні узагальненого типу. Структура Container у прикладі нижче є узагальненою, і інструкція узагальнення where вказує, які обмеження типу мають задовольнятися аби ці нові методи були доступними на її екземплярах.

extension Container {
    func average() -> Double where Item == Int {
        var sum = 0.0
        for index in 0..<count {
            sum += Double(self[index])
        }
        return sum / Double(count)
    }
    func endsWith(_ item: Item) -> Bool where Item: Equatable {
        return count >= 1 && self[count-1] == item
    }
}
let numbers = [1260, 1200, 98, 37]
print(numbers.average())
// Надрукує "648.75"
print(numbers.endsWith(37))
// Надрукує "true"

У цьому прикладі до структури Container додається метод average() для випадків, коли елементи є цілими числами, та метод endsWith(_:), для випадків, коли елементи можна перевіряти на рівність. Обидві функції мають у своєму оголошенні інструкцію узагальнення where, котра додає обмеження на параметр типу Item із початкового оголошення структури Container.

Якщо ви захочете написати цей код без контекстуальної інструкції узагальнення where, вам слід буде написати два різних розширення, кожне зі своєю інструкцією узагальнення where. Приклад вище та приклад нижче описують ідентичну поведінку.

extension Container where Item == Int {
    func average() -> Double {
        var sum = 0.0
        for index in 0..<count {
            sum += Double(self[index])
        }
        return sum / Double(count)
    }
}
extension Container where Item: Equatable {
    func endsWith(_ item: Item) -> Bool {
        return count >= 1 && self[count-1] == item
    }
}

У версії цього прикладу, що використовує контекстуальну інструкцію узагальнення where, обидві реалізації методів average() та endsWith(_:) містяться в одному розширенні, оскільки інструкції where кожного методу зазначають вимоги, які мають задовольнятись, щоб відповідний метод був доступним. Перенесення цих вимог до інструкцій where розширень робить ці методи доступними у цих же само ситуаціях, але змушує створювати по одному розширенню на кожен метод.

Асоційовані типи з інструкцією узагальнення Where

Інструкція узагальнення where може застосовуватись до асоційованого типу. Наприклад, припустимо, що вам потрібно створити версію протоколу Container, що містить у собі ітератор, аналогічно до протоколу Sequence зі стандартної бібліотеки. Ось як це можна записати:

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
}

Інструкція узагальнення where на асоційованому типі Iterator вимагає, щоб ітератор переходив по елементах того ж типу, що містяться в контейнері, незалежно від типу самого контейнера. Функція makeIterator() надає доступ до ітератора контейнера.

Якщо протокол наслідується від іншого протоколу, до нього можна додавати обмеження на успадкований асоційований тип, включаючи інструкцію узагальнення where до оголошення протоколу. Наприклад, у наступному коді оголошується протокол ComparableContainer, що вимагає, щоб тип Item був підпорядкованим протоколу Comparable:

protocol ComparableContainer: Container where Item: Comparable { }

Узагальнені індекси

Індекси також можуть бути узагальненими, і вони також можуть мати інструкцію узагальнення where. Назву замісника типу записують всередині кутових дужок після ключового слова subscript, а інструкцію узагальнення where – прямо перед кутовою дужкою, з якої починається тіло індексу. Наприклад:

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 вимагає, щоб ітератор послідовності Indices переходив по елементах типу Int. Це гарантує, що індекси у послідовності indices матимуть той же тип, що й індекси у контейнері.

Узяті разом, дані обмеження означають, що передане як параметр indices значення є послідовністю цілих чисел.