obo.dev

Обобщения (Generic)

25 Dec 2022

Обобщения (Generic)

Обобщения Swift (Generics, Дженерики, Универсальные типы) позволяют создавать гибкие конструкции без привязки к конкретным типам данных, которые можно использовать с разными типами данных.

Это помогает повторно использовать наш код.

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)

// Output
// 141

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

Но, допустим, есть необходимость расширить функцию, добавив в нее другие типы данных, такие как Float и Double?

Для этой цели можно написать новую функцию:

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

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

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

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

Обобщённая (универсальная) функция (Generic Function)

В Swift можно создать функцию, которую можно использовать с любым типом данных.

Такая функция известна как обобщённая функция (универсальная функция).

Вот как можно создать обобщённую (универсальную) функцию в Swift:

// create a generic function
func displayData<T>(data: T) {
  ...
}

Здесь,

  • создали обобщённую функцию с именем displayData();
  • T, используемый внутри угловых скобок <>, называется заполнителем имени типа или универсальным параметром типа.

И в зависимости от типа значения, переданного в функцию, T заменяется этим типом данных (Int, String и т.д.).

Так как заполнитель имени указывает на тип данных, то он пишется с большой буквы.

Примечание. Можно указать любое имя для заполнителя имени типа: <S>, <T>, <U>, <Element> и т.д. Но обычно используется <T>.

Допустимо применять при необходимости несколько заполнителей имени типа. При этом, необходимо, чтоб они отличались.

Пример. Обобщённая функция

// create a generic function
func displayData<T>(data: T) {
    print("Generic Function:")
    print("Data Passed:", data)
}

// generic function working with String
displayData(data: "Swift")

// Output
// Generic Function:
// Data Passed: Swift

// generic function working with Int
displayData(data: 5)

// Output
// Generic Function:
// Data Passed: 5

В приведенном выше примере создана обобщённая функцию с именем displayData() с параметром типа <T>.

Теперь, когда вызываем обобщённую функцию:

displayData(data: "Swift")

мы передали строковое значение, поэтому параметр-заполнитель T автоматически заменяется String.

Точно так же, когда передаем тип Int в универсальную функцию

displayData(data: 5)

заполнитель Т заменяется на Int.

Ещё пример:

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

Данная функция меняет между собой значения двух внешних переменных, которые в функции становятся входящими параметрами.

Сначала передаем в функцию значения типа Int и система автоматически в качестве параметра типа для этой функции будет использовать тип Int.

var x: Int = 25
var y: Int = 14
swap(&x, &y)
print(x)

// Output
// 14

Но теперь в эту функцию можно передать и значения других типов. Например, тип Double. Теперь система автоматически будет для параметра типа использовать тип Double.

var s1: Double = 10.2
var s2: Double = -3.6
swap(&s1, &s2)
print(s1)

// Output
// -3.6

Обобщённый (универсальный) тип. Универсальный класс (Generic Class)

Подобно универсальной функции, также можно создать пользовательский тип (классы, структуры), который можно использовать с любым типом данных. Далее будем рассматривать классы. Такой класс известен как обобщённый класс (универсальный класс).

Давайте посмотрим пример:

// create a generic class
class Information<T> {

    // property of T type
    var data: T

    init (data: T) {
        self.data = data
    }

    // method that return T type variable
    func getData() -> T {
        return self.data
    }
}

// initialize generic class with Int data
var intObj = Information<Int>(data: 6)
print("Generic Class returns:", intObj.getData())

// Output
// Generic Class returns: 6

// initialize generic class with String data
var strObj = Information<String>(data: "Swift")
print("Generic Class returns:", strObj.getData())

// Output
// Generic Class returns: Swift

В приведенном выше примере был создан обобщённый (универсальный) класс с именем Information. Этот класс можно использовать для работы с любым типом данных.

class Information<T> {...}

Было создано два объекта:

var intObj = Information<Int>(data: 6)

var strObj = Information<String>(data: "Swift")

Здесь,

  • intObj - параметр типа T заменен на Int. Теперь Information работает с целочисленными данными;
  • stringObj - параметр типа T заменен на String. Теперь Information работает со строковыми данными.

Еще пример. Например, для идентификации объекта часто используется свойство id - некий идентификатор, который отличает один объект от других. Для идентификатора, как правило, выбирается либо число, либо строка. Рассмотрим конкретный пример:

class User<T>{
     
    var id: T
    var name: String
     
    init(id: T, name: String){
         
        self.id = id
        self.name = name
    }
}
 
var tom: User = User(id: 12, name: "Tom")
var bob: User = User(id: "234nds", name: "Bob")

Для определения универсального обобщённого класса после имени класса в угловых скобках идет название универсального параметра: class User<T>.

И в данном случае свойство id будет представлять значение типа T.

После определения класса создаем два объекта: tom и bob. Переменная tom в качестве id использует число (Int), а переменная bob - строку (String).

И несмотря на то, что оба объекта представляют тип User, но с учетом универсального параметра переменная tom будет представлять тип User<Int>, а переменная bob - тип User<String>. И можно даже явно указать тип универсального параметра:

var tom: User<Int> = User<Int>(id: 12, name: "Tom")

Структуры так же могут быть универсальными. Пример - это массивы или словари, которые являются структурами и могут принимать разные типы данных:

  • Array<Int> - массив целых чисел;
  • Array<Double> - массив чисел с плавающей точкой;
  • Array<String> - массив строковых элементов;
  • Array<Any> - массив с элементами разных типов.

Ограничения типа в обобщениях

В общем виде, заполнитель имени типа может принимать любой тип данных (Int, String, Double, …).

Однако, если необходимо использовать обобщения (дженерики) только для некоторых конкретных типов (например, для приема данных числовых типов), можно использовать ограничения типов данных.

Вот как создаётся ограничения типа:

func addition<T: Numeric>(num1: T, num2: T) {
  ...
}

Здесь <T: Numeric> добавляет ограничения к параметру типа. Он определяет, что T должен соответствовать числовому протоколу Numeric.

Примечание. Numeric — это встроенный протокол для числовых значений, таких как Int и Double.

Пример с ограничениями по типу:

//create a generic function with type constraint
func addition<T: Numeric>(num1: T, num2: T) {
    print("Sum:", num1 + num2)
}

// pass Int value
addition(num1: 5, num2: 10)

// pass Double value
addition(num1: 5.5, num2: 10.8)

// Output
// Sum: 15
// Sum: 16.3

В приведенном выше примере была создана универсальная функция с именем addition(). Обратите внимание на выражение,

<T: Numeric>

Здесь универсальная функция создается с ограничениями типа. Это означает, что addition() может работать только с типами данных, которые соответствуют числовому протоколу Numeric (Int, Double и т. д.).

Примечание. Если попытаться передать другие типы, например, String, то получим ошибку: “argument type ‘String’ does not conform to the expected type ‘Numeric’” (“тип аргумента «String» не соответствует ожидаемому типу «Numeric»”).

В начале статьи, в качестве примера указывалась функция addition(a: b:), которая принимала в качестве параметров данные по типу Int. Давайте возьмем нашу функцию 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 и так далее. Она многоразовая и гибкая, и дает возможность не повторять наш код.

Давайте посмотрим на другой пример.

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

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

// Output
// 3

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

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

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

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

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

Ограничения типа в обобщениях с помощью классов

Ограничения могут быть полезны, если мы хотим, чтобы универсальный параметр мог представлять определенный класс или один из его производных классов. Например:

class Transport {

    func drive() {
        print("Транспорт едет")
    }
}

class Auto: Transport {

    override func drive() {
        print("Машина едет")
    }
}
 
func driveTransport<T: Transport>(_ transport: T) {
    transport.drive()
}
 
var myAuto: Auto = Auto()
driveTransport(myAuto)

// Output
// Машина едет

В этом примере определен класс транспортного средства Transport и производный от него класс машины - Auto.

И также здесь определена обобщённая универсальная функция driveTransport(), представляющая функцию вождения транспортного средства. Поскольку эта функция предусмотрена для любого транспортного средства, то задаем ограничение - тип Transport. Затем при вызове этой функции можно передать в нее любой объект класса Transport или один из его производных классов, например, объект класса Auto.

Установка ограничения позволяет использовать у объектов этого типа методы и свойства. Так, в данном случае функция driveTransport() вызывает метод drive() переданного в метод объекта.

Наследование и обобщённые классы

При наследовании от обобщённого базового типа, производный тип перенимает параметр базового типа:

class User<T> {
    let id: T

    init(id: T) {
        self.id = id
    }

    func displayId() {
        print(id)
    }
}

class Employee<T> : User<T> {}
class UserInt : User<Int> {}

let alice = Employee<String>(id: "5746fgg")
alice.displayId()

// Output
// 5746fgg

let bob = UserInt(id: 34)
bob.displayId()

// Output
// 34

В данном случае производный тип Employee также является обобщённым, и при создании его объекта можно типизировать его конкретным типом. А класс UserInt является необобщённым, он уже изначально типизирован типом Int.

При определении обобщений следует учитывать, что они не ковариантные. Например, в следующем случае получим ошибку:

struct Person<T> { }
class Auto { }
class Truck: Auto { }
 
let tom: Person<Auto> = Person<Truck>() // Error: Cannot assign value of type 'Person<Truck>' to type 'Person<Auto>'

Хотя класс Truck наследуется от класса Auto, нельзя присвоить переменной типа Person<Auto> объект Person<Truck>.

Преимущества дженериков Swift

1. Повторное использование кода (Code Reusability)

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

Например:

func genericFunction<T>(data: T) {...}

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

2. Используется с коллекциями

Массивы в Swift используют концепцию дженериков.

Например:

// creating a integer type array
var list1: Array<Int> = []

// creating a string type array
var list2: Array<String> = []

Здесь массив list1, содержащий значения Int, и массив list2, содержащий значения String.

Подобно массивам, словари также являются универсальными в Swift.


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

Также информацию об обобщениях (универсальных типах) можно получить на странице официальной документации.

Ссылки на официальную документацию: