obo.dev

Протоколы с ассоциированными типами и дженерики

25 Dec 2022

Протоколы с ассоциированными типами и дженерики

Как известно, протокол определяет свойства и методы, которым должен соответствовать соответствующий класс, структура или перечисление.

Рассмотрим такой пример. Представьте, что у вас есть ресторан, в котором продаются определенные продукты. Клиент приходит в ваш ресторан и хочет что-нибудь съесть. Ему все равно, что есть, главное, чтобы это было съедобно.

protocol Edible {
    func eat()
}

Протокол Edible указывает на то, что можно съесть. В протоколе указана функция eat(), которая реализует возможность есть.

Любой класс, который соответствует протоколу Edible, должен реализовать функцию eat():

class Apple: Edible {
    func eat() {
        print("Ням! Ням!")
    }
}

Протоколы позволяют нам писать гибкий и многократно используемый код. Они также помогают нам легко объединить разные части нашего кода. Клиенту не нужно знать точную реализацию того, что он собирается съесть, а только то, что у класса есть функция eat(). То есть, он может есть всё, что соответствует протоколу Edible.

Но при чём здесь дженерики?

Рассмотрим еще один пример. Вы идете в большой универмаг, к примеру в “IKEA”, чтобы купить книжный шкаф. И у вас есть два требования для книжного шкафа:

  • Вы хотите хранить в этом книжном шкафу не только книги;
  • Это не обязательно должен быть книжный шкаф, это также может быть ящик для хранения, шкафчик или комод.

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

Сначала определим простой протокол Storage:

protocol Storage {
    func store(item: Book)
    func retrieve(index: Int) -> Book
}

Протокол Storage содержит две функции: одну для хранения книг (store(item:)) и одну для получения книг по индексу (retrieve(index:)). Предположим, что Book это простая структура, которая определяет свойства title и author:

struct Book {
    var title = ""
    var author = ""
}

Любой класс может принять протокол Storage для хранения и извлечения книг:

class Bookcase: Storage {
    var books = [Book]()
 
    func store(item: Book) {
        books.append(item)
    }
 
    func retrieve(index: Int) {
        return books[index]
    }
}

Класс Bookcase хранит книги в массиве books. Он принимает функции из протокола Storage для хранения и извлечения книг.

Однако мы не просто хотим хранить книги. Мы хотим хранить любой предмет в любом хранилище. Вот где нам понадобятся дженерики.

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

protocol Storage {
    associatedtype Item
    
    func store(item: Item)
    func retrieve(index: Int) -> Item
}

Здесь был добавлен ассоциированный тип (связанный тип) Item, который связанный с ключевым словом associatedtype. Функции store(item:) и retrieve(index:) теперь используют связанный тип Item.

Теперь, вместо просто книг Book любой класс, который соответствует протоколу Storage, может хранить любой тип Item.

Реализуем протокол Storage для класса Trunk:

class Trunk<Item>: Storage {
    var items: [Item] = [Item]()
 
    func store(item: Item) {
        items.append(item)
    }
 
    func retrieve(index: Int) -> Item {
        return items[index]
    }
}

Заполнитель имени типа Item используется во всем классе.

Давайте теперь создадим объект для хранения книг:

let bookTrunk = Trunk<Book>()
bookTrunk.store(item: Book(title: "1984", author: "Джордж Оруэлл"))
bookTrunk.store(item: Book(title: "О дивный новый мир", author: "Олдос Хаксли"))
print(bookTrunk.retrieve(index: 1).title)

// Output
// О дивный новый мир

В этом примере используем структуру Book вместо заполнителя Item. Ассоциированный тип и заполнитель имени типа конкретизируются, когда определяем Trunk через Book. При этом также можно создать класс Shoe со свойствами size и brand и также хранить его в Trunk:

let shoeTrunk = Trunk<Shoe>()
shoeTrunk.store(item: Shoe(size: 42, brand: "Nike"))
shoeTrunk.store(item: Shoe(size: 99, brand: "Adidas"))
print(shoeTrunk.retrieve(index: 0).brand)

// Output
// Nike

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

class FreightShip<Item>: Storage {
    func store(item: Item) {
    
    }
 
    func retrieve(index: Int) -> Item {
    
    }
}

Протокол Storage определяет ассоциированный тип. Этот тип должен определяться классом, который принимает данный протокол.

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

Связанные типы

Связанный тип дает заполнитель (плейсхолдер) имени типу, который используется как часть протокола. Фактический тип, который будет использоваться связанным типом не указывается до тех пор, пока не будет принят протокол. Связанные типы указываются при помощи ключевого слова associatedtype.

Связанные типы и псевдонимы типов (Typealias)

Выше был приведен пример использования дженериков для типов - универсальный класс. Но также можно использовать псевдонимы типов для реализации протоколов со связанными значениями. Рассмотрим пример:

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

Протокол Container определяет три требуемых возможности, которые должен иметь любой контейнер:

  • Должна быть возможность добавлять новый элемент в контейнер при помощи метода append(_:).
  • Должна быть возможность получить доступ к количеству элементов в контейнере через свойство count, которое возвращает значение типа Int.
  • Должна быть возможность получить значение через индекс элемента (сабскрипт), который принимает значение типа Int.

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

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

Для этого протокол Container объявляет связанный тип Item, который записывается как associatedtype Item. Протокол не определяет для чего конкретно нужен Item, потому что эта информация остается для любого соответствующего протоколу класса. Тем не менее, тип Item предоставляет способ сослаться на тип элементов в Container и определить тип для использования метода append(_:) и сабскрипта subscript(i:), для того, чтобы гарантировать, что желаемое поведение любого Container выполняется.

Ниже приведена версия неуниверсального типа IntStack, который является структурой:

struct IntStack {
    // исходная реализация IntStack
    var items = [Int]()
    mutating func push(_ item: Int) {
        items.append(item)
    }
    mutating func pop() -> Int {
        return items.removeLast()
    }
}

Теперь подпишем структуру 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, так как реализована функциональность, которая указана в протоколе.

Обратите внимание на строку

    typealias Item = Int

Здесь указывается, что псевдонимом типа Item в реализации данной структуры будет тип Int.

Таким образом, структура IntStack соответствует протоколу Container и тип Item представлен в данной структуре типом Int.

Можно ли не указывать псевдоним типа typealias?

Можно. В этом случае необходимо создать универсальный тип, который соответствует протоколу, как указывалось выше. Например Stack:

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]
    }
}

Здесь, тип параметра Element указан как заполнитель типа и использован в качестве параметра item метода append(_:) и в качестве возвращаемого типа сабскрипта subscript(i:). Таким образом Swift может вывести, что Element подходящий тип для использования его в качестве типа Item для этого конкретного контейнера.

Добавление ограничений в связанный тип

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

Например, следующий код определяет версию Container, который требует, чтобы его элементы реализовывали протокол Equatable.

protocol Container {
    associatedtype Item: Equatable

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

Для соответствия данной версии Container, каждому элементу Container нужно соответствовать/реализовывать протокол Equatable.


Еще полезные ссылки