Протокольно ориентированное программирование, ч. 3
В этой части мы рассмотрим как переменные обобщенного типа хранятся и копируются и как с ними работает метод dispatch.
Необобщенная версия
Очень простой код. `drawACopy` принимает параметр типа Drawable и вызывает его метод draw - это все.
Обобщенная версия
Давайте рассмотрим обобщенную версию кода выше:
Кажется, ничего не изменилось. Мы все еще можем просто вызвать функцию drawACopy, как ее необобщенную версию, и ничего больше, но самое интересное как обычно под капотом.
Обобщенный код имеет две важные особенности:
1. статический полиморфизм (также известный как параметрический)
2. определенный и единственный тип в контексте вызова (обобщенный тип `T` определен во время компиляции)
Рассмотрим это на примере:
Самая интересная часть начинается, когда мы вызываем функцию foo. Компилятор точно знает тип переменной point - это просто Point. Более того, тип T: Drawable в функции foo может свободно выводиться компилятором с того момента, как мы передаем переменную известного типа Point этой функции: T = Point. Все типы известны во времени компиляции и компилятор может выполнить все его замечательные оптимизации - самое важное - это встроить(inline) вызов foo.
Компилятор просто встраивает вызов foo его реализацией и выводит обобщенный тип T: Drawable bar'а тоже. Иными словами сперва компилятор встраивает вызов метода `foo` с типом `T = Point`, затем уже встраивает результат прошлого встраивания - метод `bar` с типом `T = Point`.
Реализация обобщенных методов
Внутри drawACopy Swift использует протокольно-методную таблицу (которая содержит все реализации метода Т) и таблицу жизненного цикла (которая содержит все методы жизненного цикла для экземпляра Т). В псевдокоде это смотрится так:
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)`?
Мы заметили, что обобщенный код дает более статическую форму полиморфизма. Также это включает оптимизацию компилятора, называемую "Специализация обобщенного кода". Давайте посмотрим:
Опять мы имеем тот же код:
Специализация обобщенной функции создает копию со специализированными обобщенными типами этой функции. К примеру, если мы вызываем drawACopy с переменной типа Point, то компилятор создаст специализированную версию этой функции - drawACopyOfPoint(local: Point), и мы получаем:
Что может быть сокращено грубой оптимизацией компилятора до этого:
Все эти ухищрения доступны потому что обобщенные функции могут быть вызваны только если все обобщенные типы определены - в методе `drawACopy` обобщенный тип (T) отлично определен.
Обобщенные хранимые свойства
Рассмотрим простую struct Pair:
Когда мы используем это таким способом, мы получаем 2 аллокации на куче (точное состояния памяти при таком сценарии были описаны во второй части), но мы можем избежать этого с помощью обобщенного кода.
Обобщенная версия Pair выглядит так:
С момента, когда тип Т определен в обобщенной версии, типы свойств fst и snd одинаковы и тоже определены. Так как тип определен, компилятор может распределить специализированное количество памяти этих двух свойств - fst и snd.
Более детально о специализированном количестве памяти:
Когда мы работаем с необобщенной версией `Pair`, типы свойств fst и snd есть Drawable. Любой тип может соответствовать Drawable, даже если это занимает 10 Kб памяти. То есть Swift не сможет сделать вывод о размере этого типа и будет использовать универсальное расположение памяти, например экзистенциальный контейнер. Любой тип может хранится в этом контейнере. В случае обобщенного кода тип хорошо узнаваем, действительный размер свойств тоже узнаваем, и Swift может создать специализированное расположение памяти. Например (обобщенная версия):
Тип Т сейчас - Point. Point берет N байтов памяти и в Pair мы получаем два из них. Swift выделит 2 * N количество памяти и поместит pair туда.
Итак, с обобщенной версией Pair мы избавляемся от лишних аллокаций на куче, потому что типы легко узнаваемы и могут располагаться конкретно - без необходимости создания универсальных шаблонов памяти, так как все известно.
Заключение
1. Специализированный обобщенный код - Типы значений имеет лучшую скорость выполнения, так как:
+ нет размещения на куче при копировании + обобщенный код - вы пишете функцию для специализированного типа + нет подсчета ссылок + статическая отправка методов
2. Специализированный обобщенный код - ссылочные типы имеет среднюю скорость выполнения, так как:
+ аллокации на кучу при создании экземпляра + есть подсчет ссылок + динамическая отправка методов через виртуальную таблицу
3. Неспециализированный обобщенный код - маленькие значения
+ нет размещения на куче - значение помещается в буферу значений экзистенциального контейнера + нет подсчета ссылок (так как ничего не размещается на куче) + динамическая отправка методов через протокольно-методную таблицу
4. Неспециализированный обобщенный код - большие значения
+ размещение на heap - значение помещается в буфер значений + есть подсчет ссылок + динамический dispatch через протокольно-методную таблицу
Данный материал не говорит о том, что классы плохие, структуры хорошие, а структуры в сочетании с обобщенным кодом - лучшие. Мы хотим сказать, что как у программиста, у вас есть ответственность выбора инструмента для ваших задач. Классы действительно хороши, когда вам нужно сохранить большие значения и чтобы была семантика ссылок. Структуры - лучшие для маленьких значений и когда вам нужна их семантика. Протоколы лучше всего подходят к обобщенному коду и структурам, и так далее. Все инструменты специфичны для задачи, которую вы решаете, и имеют положительные и отрицательные стороны.
А также не платите за динамизм, когда он вам не нужен. Подберите подходящую абстракцию с наименьшими требованиями к времени выполнения.