Завершающая статья на тему протокольно ориентированного программирования.


В этой части мы рассмотрим как переменные обобщенного типа хранятся и копируются и как с ними работает метод dispatch.


Необобщенная версия


protocol Drawable {
  func draw()
}

func drawACopy(local: Drawable) {
  local.draw()
}

let line = Line()
drawACopy(line)

let point = Point()
drawACopy(point)

Очень простой код. drawACopy принимает параметр типа Drawable и вызывает его метод draw — это все.


Обобщенная версия


Давайте рассмотрим обобщенную версию кода выше:


func drawACopy<T: Drawable>(local: T) {
  local.draw()
}

...

Кажется, ничего не изменилось. Мы все еще можем просто вызвать функцию drawACopy, как ее необобщенную версию, и ничего больше, но самое интересное как обычно под капотом.
Обобщенный код имеет две важные особенности:


  1. статический полиморфизм (также известный как параметрический)
  2. определенный и единственный тип в контексте вызова (обобщенный тип Т определен во время компиляции)

Рассмотрим это на примере:


func foo<T: Drawable>(local: T) {
  bar(local)
}

func bar<T: Drawable>(local: T) { ... }

let point = Point(...)
foo(point)

Самая интересная часть начинается, когда мы вызываем функцию foo. Компилятор точно знает тип переменной point — это просто Point. Более того, тип T: Drawable в функции foo может свободно выводиться компилятором с того момента, как мы передаем переменную известного типа Point этой функции: T = Point. Все типы известны во времени компиляции и компилятор может выполнить все его замечательные оптимизации — самое важное — это встроить(inline) вызов foo.


This:
```swift
let point = Point(...)
foo<T = Point>(point)

Becomes this:
```swift
bar<T = Point>(point)

Компилятор просто встраивает вызов foo его реализацией и выводит обобщенный тип T: Drawable bar'а тоже. Иными словами, сперва компилятор встраивает вызов метода foo с типом T = Point, затем уже встраивает результат прошлого встраивания — метод bar с типом T = Point.


Реализация обобщенных методов


func drawACopy<T: Drawable>(local: T) {
  local.draw()
}

drawACopy(Point(...))

Внутри drawACopy Swift использует протокольно-методную таблицу (которая содержит все реализации метода Т) и таблицу жизненного цикла (которая содержит все методы жизненного цикла для экземпляра Т). В псевдокоде это смотрится так:


func drawACopy<T: Drawable>(local: T, pwt: T.PWT, vwt: T.VWT) {...}

drawACopy(Point(...), Point.pwt, Point.vwt)

VWT и PWT являются ассоциированными типами (associatedtype) у T — как псевдонимы типов (typealias), только лучше. Point.pwt и Point.vwt — статические свойства.


Так как в нашем пример Т — это Point, то Т хорошо определена, следовательно, не требуется создание контейнера. В предыдущей необобщенной версии drawACopy(local: Drawable) создание экзистенциального контейнера было осуществлено по необходимости — это мы изучили во второй части статьи.


Таблица жизненного цикла требуется в функциях из-за создания аргумента. Как мы знаем, аргументы в Swift передаются через значения, а не через ссылки, следовательно, они должны быть скопированы, и метод copy для этого аргумента принадлежит таблице жизненного цикла типа этого аргумента. Также там находятся другие методы жизненного цикла: allocate, destruct и deallocated.


Таблица жизненного цикла требуется в обобщенных функциях из-за использования методов для параметров обобщенного кода.


Обобщенный или необобщенный?


Правда ли, что использование обобщенных типов делает выполнение кода быстрее чем использование только протокольных типов? Быстрее ли обобщенная функция func foo<T: Drawable>(arg: T) чем ее "протокольный" аналог fun foo(arg: Drawable)?


Мы заметили, что обобщенный код дает более статическую форму полиморфизма. Также это включает оптимизацию компилятора, называемую "Специализация обобщенного кода". Давайте посмотрим:


Опять мы имеем тот же код:


func drawACopy<T: Drawable>(local: T) {
  local.draw()
}

drawACopy(Point(...))
drawACopt(Line(...))

Специализация обобщенной функции создает копию со специализированными обобщенными типами этой функции. К примеру, если мы вызываем drawACopy с переменной типа Point, то компилятор создаст специализированную версию этой функции — drawACopyOfPoint(local: Point), и мы получаем:


func drawACopyOfPoint(local: Point) {
  local.draw()
}

func drawACopyOfLine(local: Line) {
  local.draw()
}

drawACopy(Point(...))
drawACopt(Line(...))

Что может быть сокращено грубой оптимизацией компилятора до этого:


Point(...).draw()
Line(...).draw()

Все эти ухищрения доступны потому что обобщенные функции могут быть вызваны только если все обобщенные типы определены — в методе drawACopy обобщенный тип (T) отлично определен.


Обобщенные хранимые свойства


Рассмотрим простую struct Pair:


struct Pair {
  let fst: Drawable
  let snd: Drawable
}

let pair = Pair(fst: Line(...), snd: Line(...))

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


Обобщенная версия Pair выглядит так:


struct Pair<T: Drawable> {
  let fst: T
  let snd: T
}

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


Более детально о специализированном количестве памяти:


когда мы работаем с необобщенной версией Pair, типы свойств fst и snd есть Drawable. Любой тип может соответствовать Drawable, даже если это занимает 10 Kб памяти. То есть Swift не сможет сделать вывод о размере этого типа и будет использовать универсальное расположение памяти, например экзистенциальный контейнер. Любой тип может хранится в этом контейнере. В случае обобщенного кода тип хорошо узнаваем, действительный размер свойств тоже узнаваем, и Swift может создать специализированное расположение памяти. Например (обобщенная версия):


let pair = Pair(Point(...), Point(...))

Тип Т сейчас — Point. Point берет N байтов памяти и в Pair мы получаем два из них. Swift выделит 2 * N количество памяти и поместит pairтуда.


Итак, с обобщенной версией Pair мы избавляемся от лишних аллокаций на куче, потому что типы легко узнаваемы и могут располагаться конкретно — без необходимости создания универсальных шаблонов памяти, так как все известно.


Заключение


1. Специализированный обобщенный код — Типы значений


имеет лучшую скорость выполнения, так как:


  • нет размещения на куче при копировании
  • обобщенный код — вы пишете функцию для специализированного типа
  • нет подсчета ссылок
  • статическая отправка методов

2. Специализированный обобщенный код — ссылочные типы


имеет среднюю скорость выполнения, так как:


  • аллокации на кучу при создании экземпляра
  • есть подсчет ссылок
  • динамическая отправка методов через виртуальную таблицу

3. Неспециализированный обобщенный код — маленькие значения


  • нет размещения на куче — значение помещается в буферу значений экзистенциального контейнера
  • нет подсчета ссылок (так как ничего не размещается на куче)
  • динамическая отправка методов через протокольно-методную таблицу

4. Неспециализированный обобщенный код — большие значения


  • размещение на heap — значение помещается в буфер значений
  • есть подсчет ссылок
  • динамический dispatch через протокольно-методную таблицу

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


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


  • структурные типы — семантика значений
  • классовые типы — идентичность
  • обобщенный код — статический полиморфизм
  • протокольные типы — динамический полиморфизм

Используйте непрямое хранение, чтобы работать с большими значениями.


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


Удачи!

Комментарии (2)


  1. atom7
    08.11.2019 13:48

    Спасибо! Буду на практике экспериментировать благодаря новым знаниям.))


    1. BytePace Автор
      08.11.2019 13:49

      Рады делиться! Спасибо :)