Протоколы с ассоциированными типами и дженерики
Как известно, протокол определяет свойства и методы, которым должен соответствовать соответствующий класс, структура или перечисление.
Рассмотрим такой пример. Представьте, что у вас есть ресторан, в котором продаются определенные продукты. Клиент приходит в ваш ресторан и хочет что-нибудь съесть. Ему все равно, что есть, главное, чтобы это было съедобно.
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
.