Всем привет, меня зовут Сергей, я работаю в компании 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.
Спасибо за внимание.
Комментарии (2)
Gargo
00.00.0000 00:00Runtime будет пробовать искать селектор в Б классе, потом в А как в суперклассе. Так он доходит до самого верха, и, если не нашёл, приложение падает.
вообще-то на этом приложение пока еще не падает. Есть работа с NSInvocation, о которой даже в вики сказано:
https://ru.wikipedia.org/wiki/Objective-C
ws233
https://habr.com/ru/post/714830/