
Всем привет, меня зовут Сергей, я работаю в компании Joy Dev в должности iOS TeamLead. Эта статья - моя “проба пера” на Хабре. В ней, вместе с обзором видов диспетчеризации в Swift, мы рассмотрим несколько примеров, когда реализация методов в extension может вести себя неожиданным образом. Итак.
Диспетчеризация в Swift
Как обеспечивается высокая скорость работы приложения? Один из факторов - скорость выполнения кода. В этой статье мы рассмотрим 4 типа диспетчеризации с точки зрения производительности и приоритета использования.
Что такое диспетчеризация?
Диспетчеризация – это процесс, при котором программа выбирает, какие инструкции выполнить при вызове метода. Как правило, мы пишем код, не задумываясь, когда именно решается, какой метод будет вызван. Диспетчеризация в Swift вшита в компилятор, т.к. она разрешается на этапе компиляции. В Objective C с этим сложнее: часть решений принимается на этапе компиляции, часть в runtime.
Существует две категории диспетчеризации: статическая и динамическая. Динамическая в свою очередь делится на Witness Table и Virtual Table, которые относятся к Swift, а также Message Dispatch, относящаяся к Objective C. Я решил поговорить о последней, так как большинство разработчиков используют в своей работе UIKit, написанный на Objective C.
Статическая диспетчеризация
Она выполняется быстрее всего. Компилятор выбирает её, когда знает, что может быть только один метод или property и не может быть других переопределений или других вещей, которые совершили бы подмену этого property. По одному адресу лежит один метод. Только в этом случае компилятор выбирает статичную диспетчеризацию. Он точно будет знать: если был вызов этого метода в коде, надо пойти по этому адресу, и этот адрес и будет вшит в коде. Эта диспетчеризация очень быстрая и на этапе компиляции, и на этапе выполнения. На этапе компиляции получаем, что нам не нужно проводить поиск: есть вызов – есть адрес. То же самое на этапе выполнения: никакой проблемы решать не нужно.
Как компилятор это понимает? Private property будет брать статическую диспетчеризацию, так как модификатор private гарантирует, что данное property нельзя переопределить в классе-наследнике, и при этом модификатор private нельзя поставить к property, если он не был приватный у супер класса. Вы не сможете изменить модификатор и переопределить это property. Этот факт гарантирует, что существует 1 property в классе, это является поводом использовать инкапсуляцию в ООП.
protocol StaticDispatchExampleProtocolA { } class StaticDispatchExampleClassA: StaticDispatchExampleProtocolA { var propA: Int? func fooA() {} } class StaticDispatchExampleClassB: StaticDispatchExampleProtocolA { private func fooA() {} } class StaticDispatchExampleClassC: StaticDispatchExampleClassA { private var propA: Int? }
Модификатор final тоже может указывать на статическую диспетчеризацию, но при определенных условиях. Например, когда есть final class, который ни от кого не наследуется.
Расширения в Swift
Всё, что в них определено, будет использовать статическую диспетчеризацию. Как в этом убедиться? Если, например, в private и final сложно проверить, какая диспетчеризация получилась в итоге, то в данном случае это просто. Тот факт, что мы не можем переопределить private property у наследника, является следствием того, что Swift хочет использовать модификатор для диспетчеризации. Мы можем сделать в extension какой-то метод, затем отнаследоваться от сущности, которой мы написали extension и попробовать его переопределить. Но это не получится. Компилятор нам скажет, что переопределить метод суперкласса, который написан в extension, нельзя, ведь метод в extension использует статическую диспетчеризацию и говорит о том, что он должен быть в одном единственном экземпляре.
class StaticDispatchExampleClassA { } extension StaticDispatchExampleClassA { func fooA() {} } class StaticDispatchExampleClassB: StaticDispatchExampleClassA { func fooB() { } func fooA() { } }
Разберем другой способ, чтобы убедиться в приоритете выбора статической диспетчеризации, на одном интересном примере.
Создадим протокол, в котором объявим метод, и напишем для него реализацию в extension, «переопределим» этот метод в классе-потомке (не первом в иерархии, реализующем протокол).
protocol StaticDispatchExampleProtocolA { func foo() } extension StaticDispatchExampleProtocolA { func foo() { print("StaticDispatchExampleProtocolA") } } class StaticDispatchExampleClassA: StaticDispatchExampleProtocolA { } class StaticDispatchExampleClassB: StaticDispatchExampleClassA { func foo() { print("StaticDispatchExampleClassB") } }
Создадим переменную типа протокола и присвоим ей класс-потомок. Затем вызовем у переменной метод foo(). Создав объект класса B, мы ожидаем, что будет использовано его определение метода foo(), но в действительности свифт предпочтет обратиться статично к методу в extension-е протокола, чем строить виртуальную таблицу (его сбивает с толку отсутствие этого метода в первом реализаторе протокола).
let exampleProtocolA: StaticDispatchExampleProtocolA = StaticDispatchExampleClassB() exampleProtocolA.foo()
Таблицы
Witness и Virtual Table созданы для того, чтобы решить следующую задачу. Появляется иерархия классов, где определенный метод может быть переопределен в классе-наследнике, и необходимо определить какая именно реализация должна использоваться. Witness table хранит для каждого метода максимум две ссылки. Она используется в протокольно-ориентированном программировании. В нём есть граница: есть протокол и есть сущность. В Swift – это протокол и структура. Структуры реализуют протоколы. Witness table содержит 2 ссылки: метод существует у протокола, и метод существует у структуры или класса.
protocol WitnessDispatchExampleProtocolA { func foo() } extension WitnessDispatchExampleProtocolA { func foo() { print("WitnessDispatchExampleProtocolA") } } class WitnessDispatchExampleClassA: WitnessDispatchExampleProtocolA { func foo() { print("WitnessDispatchExampleClassA") } }
let exampleProtocolA: WitnessDispatchExampleProtocolA = WitnessDispatchExampleClassA() exampleProtocolA.foo()
Последний пример мы можем “испортить”, убрав объявление метода в протоколе. В таком случае метод не будет занесён в таблицу. И будет использована статическая диспетчеризация реализации в extension, как в предыдущих примерах.
protocol WitnessDispatchExampleProtocolA { // func foo() } extension WitnessDispatchExampleProtocolA { func foo() { print("WitnessDispatchExampleProtocolA") } } class WitnessDispatchExampleClassA: WitnessDispatchExampleProtocolA { func foo() { print("WitnessDispatchExampleClassA") } }
let exampleProtocolA: WitnessDispatchExampleProtocolA = WitnessDispatchExampleClassA() exampleProtocolA.foo()
Как мы видим, реализация методов в extension может вызывать неочевидное поведение. Следует быть очень аккуратным с этим подходом.
Virtual table нужен, когда используется наследование и переопределение, т.е. на вершине у нас протокол, а потом иерархия из нескольких классов. Каждый из классов может переопределять метод. Нам нужна большая таблица: метод один, а количество реализаций может быть множество.
protocol VirtualDispatchExampleProtocolA { func foo() } extension VirtualDispatchExampleProtocolA { func foo() { print("VirtualDispatchExampleProtocolA") } } class VirtualDispatchExampleClassA: VirtualDispatchExampleProtocolA { func foo() { print("VirtualDispatchExampleClassA") } } class VirtualDispatchExampleClassB: VirtualDispatchExampleClassA { override func foo() { print("VirtualDispatchExampleClassB") } } class VirtualDispatchExampleClassC: VirtualDispatchExampleClassB { override func foo() { print("VirtualDispatchExampleClassC") } }
let exampleProtocolA: VirtualDispatchExampleProtocolA = VirtualDispatchExampleClassC() exampleProtocolA.foo()
Четвертый тип диспетчеризации – динамический из Objective C. Это механизм отправки сообщений (message dispatch). Он работает в runtime. Для сущностей и key классов он строит таблицу из сообщений, которые могут обработать эти классы, и отсылает в runtime. Сами методы хранятся в виде строковых названий, что является селектором. Сообщение выполнить что-то – это посылка строки к объекту класса. Если он находит такой метод у класса, он его выполняет. Поиск селектора выполняется по иерархии снизу-вверх. Runtime будет пробовать искать селектор в Б классе, потом в А как в суперклассе. Так он доходит до самого верха, и, если не нашёл, приложение падает.
Это самый медленный тип диспетчеризации. Она работает в 7 раз медленнее, чем статическая. Иными словами, градации по скорости можно представить так:
Статическая диспетчеризация;
Witness table;
Virtual table;
Динамическая диспетчеризация.
Как только мы переходим к message dispatch, вызов исполняется в семь раз медленнее, чем в случае со статической диспетчеризацией.
Как понять, когда используется динамическая диспетчеризация? Если говорить про Swift, то это property, отмеченные как dynamic. А также некоторые методы и свойства отмеченные @objc. В данном случае динамическая диспетчеризация будет использоваться не всегда. Если вы напишите метод, отметите его @objc, но не будете передавать как селектор и вызовете просто так, Swift применит к нему ту диспетчеризацию, которая сейчас наиболее уместна. Например, Swift для приватного метода выберет статическую диспетчеризацию. А там, где он используется как селектор, будет применяться динамическая диспетчеризация. Сама аннотация @objc нужна только для того, чтобы при компиляции obj-c runtime сгенерировал для себя таблицы селекторов и имел возможность вызвать их.
На следующих небольших фрагментах можно наблюдать несколько интересных сценариев.
Мы можем задать в протоколе дефолтную имплементацию методам, но она никогда не вызовется, потому что в структуре механизма message dispatch участвуют только классы в иерархии.
Мы можем переопределить метод в extension, потому что наличие определения для каждого класса будет резолвиться в runtime, и уже не имеет значения, где он определен при message dispatch.
@objc protocol MessageDispatchExampleProtocolA: NSObjectProtocol { @objc dynamic func foo() @objc dynamic func viewDidLoad() } extension MessageDispatchExampleProtocolA { dynamic func foo() { print("MessageDispatchExampleProtocolA foo") } dynamic func viewDidLoad() { print("MessageDispatchExampleProtocolA viewDidLoad") } } class MessageDispatchExampleVCA: UIViewController, MessageDispatchExampleProtocolA { @objc dynamic func foo() { print("MessageDispatchExampleVCA foo") } } class MessageDispatchExampleVCB: MessageDispatchExampleVCA { } extension MessageDispatchExampleVCB { @objc dynamic override func foo() { print("MessageDispatchExampleVCB foo") } @objc dynamic override func viewDidLoad() { super.viewDidLoad() print("MessageDispatchExampleVCB viewDidLoad") } }
let exampleProtocolA: MessageDispatchExampleProtocolA = MessageDispatchExampleVCB() exampleProtocolA.foo() exampleProtocolA.viewDidLoad()
Как мы видим, понимание работы диспетчеризации вызовов может помочь разработчикам лучше понимать внутренние процессы Swift, повысить скорость выполнения кода, а также уберечь себя от увлекательного дебага необычного поведения extension.
Спасибо за внимание.

