Протокольно-Ориентированное Программирование, Часть 2

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

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


Типы протоколов

Реализация полиморфизма без наследования и ссылочных типов:
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

protocol Drawable { func draw() } struct Point: Drawable { var x, y: Int func draw() { .

} } struct Line: Drawable { var x1, x2, y1, y2: Int func draw() { .

} } var drawbles = [Drawable]() for d in drawbles { d.draw() }

  1. Обозначим протокол Drawable, у которого есть метод отрисовки
  2. Давайте реализуем этот протокол для Point и Line — теперь вы можете обращаться с ними как с Drawable (вызвать метод draw)
У нас все еще есть полиморфный код. Элемент d массива drawables имеет один интерфейс, который обозначается в протоколе Drawable, но имеет разные реализации своих методов, которые обозначаются Line и Point.
Основной принцип (ad-hoc) полиморфизма: «Общий интерфейс – множество реализаций»
Динамическая отправка без виртуальной таблицы Напомним, что определение корректной реализации метода при работе с классами (ссылочными типами) достигается за счет динамической диспетчеризации и виртуальной таблицы.

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

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

Все это необходимо из-за возможности наследования и переопределения методов.

В случае структур наследование, а также переопределение методов невозможно.

Тогда, на первый взгляд, в виртуал-столе нет необходимости, но как тогда будет работать динамическая отправка? Как программа может понять, какой метод будет вызван в d.draw()?

Стоит отметить, что количество реализаций этого метода равно количеству типов, соответствующих протоколу Drawable.


Таблица свидетелей протокола

есть ответ на этот вопрос.

Эта таблица есть у каждого типа, реализовавшего какой-либо протокол.

Подобно виртуальной таблице классов, она хранит реализации методов, требуемых протоколом.

в будущем таблица протоколов-свидетелей будет называться «таблицей протокола-метода».

Отлично, теперь мы знаем, где искать реализации методов.

Осталось только два вопроса:

  1. Как найти соответствующую таблицу протокол-метод для конкретного объекта, реализующего этот протокол? В нашем случае, как мы можем найти эту таблицу для элемента d массива drawables?
  2. Элементы массива должны быть одинакового размера (в этом суть массива).

    Тогда как же рисуемый массив может удовлетворить этому требованию, если он может хранить как линию, так и точку, и они имеют разные размеры?



MemoryLayout.size(ofValue: Line(.

)) // 32 bits MemoryLayout.size(ofValue: Point(.

)) // 16 bits



Ээкзистенциальный контейнер

Чтобы решить эти две проблемы, Swift использует специальную схему хранения экземпляров типов протоколов, называемую экзистенциальным контейнером.

Она выглядит так:

Протокольно-ориентированное программирование, часть 2

Принимает 5 машинных слов (в 64-битной системе 5 * 64 = 320 бит).

Разделен на три части: буфер значений — пространство для самого экземпляра vwt — указатель на таблицу-свидетеля значений pwt - указатель на таблицу свидетелей протокола Давайте подробнее рассмотрим все три части: Буфер контента Всего три машинных слова для хранения экземпляра.

Если экземпляр может поместиться в буфере содержимого, он сохраняется там.

Если экземпляр больше 3-х машинных слов, то он не поместится в буфер и программа вынуждена выделить память в куче, поместить туда экземпляр и поместить указатель на эту память в буфер содержимого.

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

let point: Drawable = Point(.

)

Point() принимает 2 машинных слова и идеально помещается в буфер значений — программа добавит их туда:

Протокольно-ориентированное программирование, часть 2



let line: Drawable = Line(.

)

Line() занимает 4 машинных слова и не может поместиться в буфер значений — программа выделит для нее память в куче и добавит указатель на эту память в буфер значений:

Протокольно-ориентированное программирование, часть 2

ptr указывает на экземпляр Line(), расположенный в куче:

Протокольно-ориентированное программирование, часть 2

Таблица жизненного цикла Как и таблица протокол-метод, эта таблица есть у каждого типа, соответствующего протоколу.

Содержит реализацию четырех методов: выделить, скопировать, уничтожить, освободить.

Эти методы контролируют весь жизненный цикл объекта.

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

  1. При создании объекта ( Point(.

    ) as Drawable) вызывается метод allocate из Т.

    Ж.

    Ц.

    этот объект. Метод allocate решит, где следует разместить содержимое объекта (в буфере значений или в куче), и если оно должно быть выделено в куче, он выделит необходимый объем памяти.

  2. Метод копирования поместит содержимое объекта в соответствующее место.

  3. После окончания работы с объектом будет вызван метод destruct, который уменьшит все счетчики ссылок, если они есть.

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

Таблица протокол-метод Как описано выше, он содержит реализации методов, требуемых протоколом для типа, к которому привязана эта таблица.

Ээкзистенциальный контейнер - Ответы Таким образом, мы ответили на два поставленных вопроса:

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

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

    Если содержимое элемента невозможно поместить в буфер значений, оно будет размещено в куче.

    Если это возможно, то все содержимое будет помещено в буфер значений.

    В любом случае мы получаем, что размер объекта с типом протокола составляет 5 машинных слов (40 бит), а из этого следует, что все элементы массива будут иметь одинаковый размер.



let line: Drawable = Line(.

) MemoryLayout.size(ofValue: line) // 40 bits let drawables: [Drawable] = [Line(.

), Point(.

), Line(.

)] MemoryLayout.size(ofValue: drawables._content) // 120 bits

Ээкзистенциальный контейнер — пример Рассмотрим поведение экзистенциального контейнера в этом коде:

func drawACopy(local: Drawable) { local.draw() } let val: Drawable = Line(.

) drawACopy(val)

Экзистенциальный контейнер можно представить следующим образом:

struct ExistContDrawable { var valueBuffer: (Int, Int, Int) var vwt: ValueWitnessTable var pwt: ProtocolWitnessTable }

Псевдокод За кулисами функция drawACopy принимает ExistContDrawable:

func drawACopy(val: ExistContDrawable) { .

}

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

func drawACopy(val: ExistContDrawable) { var local = ExistContDrawable() let vwt = val.vwt let pwt = val.pwt local.type = type local.pwt = pwt .

}

Мы решаем, где будет храниться контент (в буфере или куче).

Вызовите vwt.allocate и vwt.copy, чтобы заполнить local содержимым val:

func drawACopy(val: ExistContDrawable) { .

vwt.allocateBufferAndCopy(&local, val) }

Вызываем метод draw и передаем ему указатель на себя (метод projectBuffer сам решит, где находится self — в буфере или в куче — и вернет правильный указатель):

func drawACopy(val: ExistContDrawable) { .

pwt.draw(vwt.projectBuffer(&local)) }

Заканчиваем работу с local. Очищаем все ссылки на хип от локальных.

Функция возвращает значение — очищаем всю память, выделенную для drawACopy (фрейм стека):

func drawACopy(val: ExistContDrawable) { .

vwt.destructAndDeallocateBuffer(&local) }

Ээкзистенциальный контейнер – Цель Использование экзистенциального контейнера требует большой работы — пример выше это подтвердил — но зачем он вообще нужен, какова цель? Цель состоит в том, чтобы реализовать полиморфизм с использованием протоколов и типов, которые их реализуют. В ООП мы используем абстрактные классы и наследуем их, переопределяя методы.

В POP мы используем протоколы и реализуем их требования.

Опять же, даже при использовании протоколов реализация полиморфизма — это большая работа и энергозатратность.

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

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

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

Фактически важно отметить, что структура, хранящаяся в экзистенциальном контейнере, сохранит семантику своего типа значения независимо от того, помещена ли она в стек или в кучу.

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

Ээкзистенциальный контейнер — сохраненные свойства Мы рассмотрели, как переменная типа протокола передается и используется функцией.

Давайте посмотрим, как хранятся такие переменные:

struct Pair { init(_ f: Drawable, _ s: Drawable) { first = f second = s } var first: Drawable var second: Drawable } var pair = Pair(Line(), Point())

Как эти две структуры Drawable хранятся внутри структуры Pair? Каково содержание пары? Он состоит из двух экзистенциальных контейнеров — один для первого, другой для второго.

Строка не помещается в буфер и размещается в куче.

Точка помещается в буфер.

Это также позволяет структуре Pair хранить объекты разных размеров:

pair.second = Line()

Теперь содержимое второго тоже помещается в кучу, так как не поместилось в буфер.

Давайте посмотрим, к чему это может привести:

let aLine = Line(.

) let pair = Pair(aLine, aLine) let copy = pair

После выполнения этого кода программа получит следующее состояние памяти:

Протокольно-ориентированное программирование, часть 2

У нас в куче 4 выделения памяти, что нехорошо.

Попробуем это исправить:

  1. Создадим класс аналог Line


class LineStorage: Drawable { var x1, y1, x2, y2: Double func draw() {} }

  1. Давайте использовать это в паре


let lineStorage = LineStorage(.

) let pair = Pair(lineStorage, lineStorage) let copy = pair

Получаем одно размещение в куче и 4 указателя на него:

Протокольно-ориентированное программирование, часть 2

Но мы имеем дело с референтным поведением.

Изменение copy.first повлияет на пару.

first (то же самое и на .

секунду), а это не всегда то, что нам нужно.

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

Давайте посмотрим, как можно реализовать собственную структуру, которая копируется при изменении:

struct BetterLine: Drawable { private var storage: LineStorage init() { storage = LineStorage((0, 0), (10, 10)) } func draw() -> Double { .

} mutating func move() { if !isKnownUniquelyReferenced(&storage) { storage = LineStorage(self.storage) } // storage editing } }

  1. BetterLine хранит все свойства в хранилище, а хранилище является классом и хранится в куче.

  2. Память можно изменить только с помощью метода перемещения.

    В нем мы проверяем, что на память указывает только один указатель.

    Если указателей больше, то этот BetterLine с кем-то делит хранилище, и чтобы BetterLine полностью вел себя как структура, хранилище должно быть индивидуальным — делаем копию и дальше работаем с ней.

Давайте посмотрим, как это работает в памяти:

let aLine = BetterLine() let pair = Pair(aLine, aLine) let copy = pair copy.second.x1 = 3.0

В результате выполнения этого кода получим:

Протокольно-ориентированное программирование, часть 2

Другими словами, у нас есть два экземпляра Pair, которые используют одно хранилище: LineStorage. При изменении хранилища у одного из своих пользователей (первого/второго) для этого пользователя будет создана отдельная копия хранилища, чтобы ее изменение не повлияло на других.

Это решает проблему нарушения семантики типов значений из предыдущего примера.



Типы протоколов — сводка

  1. Маленькие значения .

    Если мы работаем с объектами, которые занимают мало памяти и могут быть помещены в буфер экзистенциального контейнера, то:

  • не будет никакого распределения кучи
  • нет подсчета ссылок
  • полиморфизм (динамическая отправка) с использованием таблицы протоколов
  1. Большие значения.

    Если мы работаем с объектами, которые не помещаются в буфер, то:

  • размещение кучи
  • подсчет ссылок, если объекты содержат ссылки.

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

Мы обнаружили, что типы протоколов, как и классы, способны реализовывать полиморфизм.

Это происходит за счет хранения в экзистенциальном контейнере и использования таблиц протоколов — таблицы жизненного цикла и таблицы протокол-метод. Теги: #Swift #copy #протокольно-ориентированное программирование #buffer #draw #свидетельская таблица протокола #существующий контейнер #drawable #allocate #destruct #deallocate #value buffer

Вместе с данным постом часто просматривают: