Всем привет, меня зовут Сергей, я работаю в компании 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 раз медленнее, чем статическая. Иными словами, градации по скорости можно представить так: 

  1. Статическая диспетчеризация;

  2. Witness table;

  3. Virtual table;

  4. Динамическая диспетчеризация.

Как только мы переходим к message dispatch, вызов исполняется в семь раз медленнее, чем в случае со статической диспетчеризацией.

Как понять, когда используется динамическая диспетчеризация? Если говорить про Swift, то это property, отмеченные как dynamic. А также некоторые методы и свойства отмеченные @objc. В данном случае динамическая диспетчеризация будет использоваться не всегда. Если вы напишите метод, отметите его @objc, но не будете передавать как селектор и вызовете просто так, Swift применит к нему ту диспетчеризацию, которая сейчас наиболее уместна. Например, Swift для приватного метода выберет статическую диспетчеризацию. А там, где он используется как селектор, будет применяться динамическая диспетчеризация. Сама аннотация @objc нужна только для того, чтобы при компиляции obj-c runtime сгенерировал для себя таблицы селекторов и имел возможность вызвать их.

На следующих небольших фрагментах можно наблюдать несколько интересных сценариев.

  1. Мы можем задать в протоколе дефолтную имплементацию методам, но она никогда не вызовется, потому что в структуре механизма message dispatch участвуют только классы в иерархии.

  2. Мы можем переопределить метод в 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)



  1. Gargo
    00.00.0000 00:00

    Runtime будет пробовать искать селектор в Б классе, потом в А как в суперклассе. Так он доходит до самого верха, и, если не нашёл, приложение падает.

    вообще-то на этом приложение пока еще не падает. Есть работа с NSInvocation, о которой даже в вики сказано:
    https://ru.wikipedia.org/wiki/Objective-C