Дженерики в Swift: как писать универсальный и гибкий код?

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

Как использовать дженерики в Swift?

Как мы знаем, Swift – это язык со строгой типизацией. Если переменная объявлена ​​как String, вы не можете присвоить ей значение типа Int.

var text:String = "Hello world!"
text = 5
// error: cannot assign value of type 'Int' to type 'String'

Строгая типизация хорошая вещь, потому что она помогает избежать ошибок в программировании. Но что, если вы хотите быть более гибкими при работе с разными типами данных?

Допустим, вы создали простую функцию, которая добавляет одно число к другому:

func addition(a: Int, b: Int) -> Int {
    return a + b
}
 
let result = addition(a: 42, b: 99)
print(result)
// 141

Функция принимает два параметра типа Int и возвращает значение типа Int.

Что если вы хотите расширить свою функцию, добавив в нее другие типы данных, такие как Floatи Double? Для этой цели вы можете написать новую функцию:

func addition(a: Double, b: Double) -> Double {
    return a + b
}

Однако в данном случае ваш код повторяется.

Дженерики позволяют сделать ваш код многократно используемым, без необходимости конкретно определять типы, с которыми может работать функция.

Использование дженериков в функциях в качестве заполнителей типов

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

Давайте возьмем нашу функцию addition(a: b:) и превратим ее в дженерик функцию:

func addition<T: Numeric>(a: T, b: T) -> T {
    return a + b
}

Вместо типа Int параметры и возвращаемый тип функции имеют тип T, который называется заполнителем имени типа.

Заполнитель T не указывает, какой именно тип данных содержит T. Он указывает только на то, что оба параметра a и b, а также возвращаемое значение функции должны быть одного типа.

Дженерик функция addition(a: b:) может принимать любой тип, если он соответствует протоколу Numeric. Это позволяет нам использовать типы Int, Double, Float и так далее. Она многоразовая и гибкая, и дает нам возможность не повторять наш код.

Ограничения типа

Синтаксис

<T: Numeric>

добавляет ограничение типа в заполнитель имени. Он определяет, что тип должен соответствовать протоколу Numeric. Это встроенный протокол Swift для любых числовых значений, таких как Int или Double.

Иными словами, вы не можете использовать функцию addition(a: b:) для добавления двух UILabel объектов. Вы можете использовать ее только для значений, которые соответствуют протоколу Numeric.

Давайте посмотрим на другой пример. Это дженерик функция, которая позволяет найти индекс значения в массиве:

func findIndex<T>(of foundItem: T, in items: [T]) -> Int? {
    for (index, item) in items.enumerated()
    {
        if item == foundItem {
            return index
        }
    }
    return nil
}

Данная функция принимает параметр foundItem в items массиве, который она сравнивает с каждым элементом массива с помощью цикла. Когда совпадение найдено, возвращается index найденного элемента. Функция возвращает nil, когда не может найти элемент, поэтому тип возвращаемого значения обозначен как Int?

Заполнитель имени типа T используется в объявлении функции. Он сообщает Swift, что эта функция может принимать любой элемент в любом массиве.

Вот пример использования данной функции:

let names = ["Ford", "Arthur", "Trillian", "Zaphod", "Deep Thought"]
 
if let result = findIndex(of: "Zaphod", in: names) {
    print(result)
    // 3
}

К сожалению, данная функция не компилируется. Нам нужно установить тип ограничения на T.

Мы используем оператор равенства == в функции, чтобы определить, равны ли два элемента, и это означает, что T должен соответствовать протоколу Equatable. В противном случае мы не сможем использовать оператор ==.

findIndex<T: Equatable>(of foundItem: T, in items: [T]) -> Int?

Swift предоставляет несколько основных протоколов:

  • Equatable для значений, которые могут быть равны или не равны.
  • Comparable для значений, которые можно сравнить, как a > b.
  • Hashable для значений, которые можно «хэшировать».
  • CustomStringConvertible для значений, которые могут быть представлены в виде строки.
  • Numericи SignedNumeric для значений, которые являются числами.

Дженерики, протоколы и ассоциированные типы

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

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

protocol Edible {
    func 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 содержит две функции: одну для хранения книг и одну для получения книг по индексу. Предположим, что 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)
// О дивный новый мир

Мы используем структуру 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)
// Nike

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

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

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

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

Читайте также:
Добавить комментарий

Ваш адрес email не будет опубликован.