Сила дженериков в Swift. Часть 1

Автор оригинала: Aaina Jain
  • Перевод
Всем привет! Делимся с вами переводом, подготовленным специально для студентов курса «iOS Разработчик. Продвинутый курс». Приятного прочтения.



Generic-функция, generic-тип и ограничения типа

Что такое дженерики?


Когда они работают – вы их любите, а когда нет – ненавидите!

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

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

Generic код позволяет создавать многократно используемые функции и типы данных, которые могут работать с любым типом, отвечающем определенным ограничениям, обеспечивая при этом типобезопасность во время компиляции. Этот подход позволяет писать код, который помогает избежать дублирования и выражает свой функционал в понятной абстрактной манере. Например, такие типы как Array, Set и Dictionary используют дженерики для хранения элементов.

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

let intArray = [1, 2, 3, 4]
let stringArray = [a, b, c, d]
func printInts(array: [Int]) {
  print(intArray.map { $0 })
}
func printStrings(array: [String]) {
  print(stringArray.map { $0 })
}

Теперь мне нужно вывести массив элементов типа float или массив пользовательских объектов. Если мы посмотрим на функции выше, то увидим, что используется только разница в типе. Поэтому вместо того, чтобы дублировать код, мы можем написать generic-функцию для повторного использования.

История дженериков в Swift




Generic-функции


Generic-функция может работать с любым универсальным параметром типа T. Имя типа ничего не говорит о том, каким должно быть Т, но оно говорит, что оба массива должны быть типа Т, независимо от того, что Т из себя представляет. Сам тип для использования вместо Т определяется каждый раз при вызове функции print(_:).

func print<T>(array: [T]) {
  print(array.map { $0 })
}

Универсальные типы или параметрический полиморфизм


Универсальный тип Т из примера выше – это параметр типа. Можно указать несколько параметров типа, записав несколько имен параметров типа в угловые скобки, разделив их запятыми.

Если посмотреть на Array и Dictionary<Key, Element>, то можно заметить, что у них есть именованные параметры типа, то есть Element и Key, Element, которые говорит о связи между параметром типа и generic-типом или функцией, в которой он используется.

Примечание: Всегда давайте имена параметрам типа в нотации СamelCase (например, T и TypeParameter), чтобы показать, что они являются названием для типа, а не значением.

Generic-типы


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

Давайте создадим стек

import Foundation

enum StackError: Error {
    case Empty(message: String)
}

public struct Stack {
    var array: [Int] = []
    
    init(capacity: Int) {
        array.reserveCapacity(capacity)
    }
    
    public mutating func push(element: Int) {
        array.append(element)
    }
    
    public mutating func pop() -> Int? {
        return array.popLast()
    }
    
    public func peek() throws -> Int {
        guard !isEmpty(), let lastElement = array.last else {
            throw StackError.Empty(message: "Array is empty")
        }
        return lastElement
    }
    
    func isEmpty() -> Bool {
        return array.isEmpty
    }   
}

extension Stack: CustomStringConvertible {
    public var description: String {
        let elements = array.map{ "\($0)" }.joined(separator: "\n")
        return elements
    }
}

var stack = Stack(capacity: 10)
stack.push(element: 1)
stack.push(element: 2)
print(stack)
stack.pop()
stack.pop()

stack.push(element: 5)

stack.push(element: 3)
stack.push(element: 4)
print(stack)

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

enum StackError: Error {
    case Empty(message: String)
}

public struct Stack<T> {
    var array: [T] = []
    
    init(capacity: Int) {
        array.reserveCapacity(capacity)
    }
    
    public mutating func push(element: T) {
        array.append(element)
    }
    
    public mutating func pop() -> T? {
        return array.popLast()
    }
    
    public func peek() throws -> T {
        guard !isEmpty(), let lastElement = array.last else {
            throw StackError.Empty(message: "Array is empty")
        }
        return lastElement
    }
    
    func isEmpty() -> Bool {
        return array.isEmpty
    }
}

extension Stack: CustomStringConvertible {
    public var description: String {
        let elements = array.map{ "\($0)" }.joined(separator: "\n")
        return elements
    }
}

var stack = Stack<Int>(capacity: 10)
stack.push(element: 1)
stack.push(element: 2)
print(stack)

var strigStack = Stack<String>(capacity: 10)
strigStack.push(element: "aaina")
print(strigStack)

Ограничения Generic-типов


Поскольку дженерик может быть любого типа, многого с ним сделать не получится. Иногда полезно применять ограничения к типам, которые могут использоваться с generic-функциями или generic-типами. Ограничения типа указывают на то, что параметр типа должен соответствовать определенному протоколу или составу протокола.

Например, тип Dictionary в Swift накладывает ограничения на типы, которые могут использоваться в качестве ключей для словаря. Словарь требует, чтобы ключи были хэшируемыми для того, чтобы иметь возможность проверить, содержит ли он уже значения для определенного ключа.

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
       // function body goes here
}

По сути, мы создали стек типа Т, но мы не можем сравнивать два стека, поскольку здесь типы не соответствуют Equatable. Нам нужно изменить это, чтобы использовать Stack<T: Equatable>.

Как работают дженерики? Посмотрим на пример.

func min<T: Comparable>(_ x: T, _ y: T) -> T {
       return y < x ? y : x
}

Компилятору не хватает двух вещей, необходимых для создания кода функции:

  • Размеров переменных типа Т;
  • Адреса конкретной перегрузки функции <, которая должна вызываться во время выполнения.

Всякий раз, когда компилятор встречает значение, которое имеет тип generic, он помещает значение в контейнер. Этот контейнер имеет фиксированный размер для хранения значения. В случае, если значение слишком велико, Swift аллоцирует его в куче и сохраняет ссылку на него в контейнере.

Компилятор также поддерживает список из одной или нескольких witness-таблиц для каждого generic-параметра: одна witness-таблица для значений, плюс по одной witness-таблице для каждого протокола-ограничения на тип. Witness-таблицы используются чтобы динамически отправлять вызовы функции в нужные реализации во время выполнения.

Конец первой части. По устоявшейся традиции ждем ваши комментарии, друзья.

Вторая часть
OTUS. Онлайн-образование
762,20
Цифровые навыки от ведущих экспертов
Поделиться публикацией

Похожие публикации

Комментарии 10

    +1

    А вы не интересовались реальной ситуацией с контейнерами для дженериков? Если я не ошибаюсь, то находя в коде вызов дженерик метода, компилятор просто создает копию метода для конкретного типа. Поэтому количество вызовов дженерик методов может влиять на размер бинарника. Что касается контейнера и выделение памяти, это автор с экземплярами протокольного типа в рантайме путает.

      0
      Вроде не ошибаетесь. Ellie Shin из Uber неплохо раскрыла эту проблему в своем докладе Putting Your App on a Diet
        –1
        Ну вообще если мы возьмем код стека на темплейтах из статьи, сгенерим две переменных с интовым стеком и одну со стринговым, а потом выдернуть у них типы через type(of:), то типы у интовых совпадут, а у стрингового будет другой. Будь там реально контейнер совпали бы все три ИМХО.
        К тому же я почти уверен что это все обертка над плюсовыми темплейтами, а в C++ все темплейты разрешаются на этапе компиляции
      +1
      Все хорошо, вот ток смущает «iOS разработчик. Продвинутый курс».
      Не понятно почему простейшая работа с generic-ами стала продвинутым курсов — в swiftbook это есть, и расписано подробней.

      При этом в статье нет рассказа о всяких изощрённых использований generic, о том как они интересно с автовывоводом типов сочетаются, ну и о том как внутри работает.
        0
        Я не являясь разработчиком iOS и разработчиком вообще, хотя и имею кое какие знания в этом успешно прошел их тестирование на оценку Б, типа ваших знаний достаточно чтобы записаться на курс. Все, что нужно знать о продвинутости.
          0
          Добрый день. Тестирование оценивает лишь базовые знания, для понимания того, потяните ли Вы программу. Для понимания формата обучения можете посмотреть запись открытого урока, а также ознакомиться с программой курса.
          0
          Перевод данной статьи не имеет отношения к программе курса. Это просто интересная информация и не более.
          0

          Замечание. "TypeParameter" — это PascalCase, а camelCase — с горбом посередине.
          Правда, с прыгающим капсом есть путаница и разногласия (и русская вика пишет иное, чем английская). Поэтому настаивать не буду, но, как говорится, JFYI.

            0
            Вы ещё вспомните, что по правильному дженерики — обобщения (по аналогии с обобщённым программированием).
              0

              По-правильному, заимствовать можно и транскрипцией, и калькой. Дженерики заимствованы так, шаблоны этак, обобщённое программирование ещё этак.


              А вот то, что ПаскальКейс и кемелКейс суть разные стили, — как бы очевидно.

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

          Самое читаемое