Завершающая статья на тему протокольно ориентированного программирования.
В этой части мы рассмотрим как переменные обобщенного типа хранятся и копируются и как с ними работает метод 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
, как ее необобщенную версию, и ничего больше, но самое интересное как обычно под капотом.
Обобщенный код имеет две важные особенности:
- статический полиморфизм (также известный как параметрический)
- определенный и единственный тип в контексте вызова (обобщенный тип Т определен во время компиляции)
Рассмотрим это на примере:
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 через протокольно-методную таблицу
Данный материал не говорит о том, что классы плохие, структуры хорошие, а структуры в сочетании с обобщенным кодом — лучшие. Мы хотим сказать, что как у программиста, у вас есть ответственность выбора инструмента для ваших задач. Классы действительно хороши, когда вам нужно сохранить большие значения и чтобы была семантика ссылок. Структуры — лучшие для маленьких значений и когда вам нужна их семантика. Протоколы лучше всего подходят к обобщенному коду и структурам, и так далее. Все инструменты специфичны для задачи, которую вы решаете, и имеют положительные и отрицательные стороны.
А также не платите за динамизм, когда он вам не нужен. Подберите подходящую абстракцию с наименьшими требованиями к времени выполнения.
- структурные типы — семантика значений
- классовые типы — идентичность
- обобщенный код — статический полиморфизм
- протокольные типы — динамический полиморфизм
Используйте непрямое хранение, чтобы работать с большими значениями.
И не забывайте — это ваша ответственность — выбирать правильный инструмент.
Спасибо за внимание к данной теме. Надеемся, что эти статьи вам помогли и были интересны.
Удачи!
atom7
Спасибо! Буду на практике экспериментировать благодаря новым знаниям.))
BytePace Автор
Рады делиться! Спасибо :)