Method Dispatch

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

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

class Parent { func foo() { print("родитель") } } class Child: Parent { override func foo() { print("наследник") } } let object: Parent = Child() object.foo() // "наследник"

Виды диспетчеризации в Swift

Диспетчеризацию можно разделить на два типа:

  • Статическая – где реализация выбирается на этапе компиляции
  • Динамическая – где реализация выбирается во время выполнения (в рантайме)

В свою очередь динамическая диспетчеризация делится на:

  • Virtual Table
  • Witness Table
  • Message Dispatch

Static Dispatch или Direct Dispatch

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

Так работают:

  • методы struct и enum, т.к. у них нет наследования и override, как у классов
  • final методы и методы final class, т.к. их нельзя переопределить
  • методы, помеченные как private, т.к. снаружи их всё равно не переопределить
  • static методы
  • обычные функции, не привязанные к типу
  • методы, которые есть только в extension протокола и не объявлены в самом протоколе
  • методы, объявленные в extension у struct, enum и class (без @objc), т.к. такие методы тоже нельзя переопределить
// Структуры struct Dog { func speak() { print("Гав") } } let dog = Dog() dog.speak() // Перечисления enum Direction { case left case right func swapped() -> Direction { self == .left ? Self.right : Self.left } } let direction = Direction.left direction.swapped() // Классы без наследования final class Cat { func speak() { print("Мяу") } } let cat = Cat() cat.speak() // class ChildCat: Cat { } // Ошибка, наследования запрещено // Расширение протокола. Работает базовая реализация метода speak(). protocol Animal { } extension Animal { func speak() { print("Не определено") } } extension Cat: Animal {} let animalCat: Animal = Cat() animalCat.speak() // Не определено (потому что метод не указан в протоколе) // static, private и final функции, а также extension class NonFinalClass { static func staticFoo() { } private func privateFoo() { } final func finalFoo() { } } extension NonFinalClass { func extensionFoo() { } } class ChildNonFinalClass: NonFinalClass { // override static func staticFoo() {} // Ошибка, нельзя переопределить static метод // override func privateFoo() {} // Ошибка, не видно метод privateFoo у родителя для переопределения // override func extensionFoo() {} // Ошибка, Non-'@objc' метод нельзя переопределить // override func finalFoo() {} // Ошибка, final метод нельзя переопределить } // Обычный вызов функции func foo() { print("Пусто") } foo()

Virtual Table

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

Так работают:

  • методы экземпляра у class, которые можно переопределить в наследнике
  • class func, т.к. их тоже можно переопределить через override
  • свойства у class вместе с их get и set, а также сабскрипты, если их можно переопределить

Для каждого класса создается виртуальная таблица (virtual table или vtable), которая живет в памяти программы, пока та запущена. Она является своего рода справочником с указателями на методы класса. Все экземпляры этого класса будут ссылаться на одну и ту же таблицу.

Что же происходит при наследовании? Для наследника создается своя виртуальная таблица (это же отдельный класс). Для методов, которые были унаследованы от родителя, поля таблицы копируются из родительской vtable. Если же какой-то метод переопределен, то в таблице дочернего класса адрес метода перезапишется.

Article_MethodDispatch_1
Virtual Table
class Parent { func speak() { print("исходный метод") } func eat() { print("исходный метод") } } class Child: Parent { override func speak() { print("переопределенный метод") } func play() { print("новый метод") } } // Тип переменной (Parent) определяет только доступные для вызова методы. В данном случае: speak() и eat(). let child1: Parent = Child() // Сами методы вызываются в соответствии с типом объекта. В данном случае это Child. // В соответствии с таблицей vtable у Child: child1.speak() // переопределенный метод child1.eat() // исходный метод // child1.play() // Ошибка, у Parent нет метода play() // Тут тип переменной (Child) открывает нам все три метода: speak(), eat(), play(). let child2: Child = Child() child2.play() // новый метод

Witness Table

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

protocol Animal { func speak() } struct Dog: Animal { func speak() {} } let dog = Dog() dog.speak() // static! let animal: Animal = Dog() animal.speak() // witness table

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

Witness table создается не для типа конкретно (как для классов), а для связки Тип + Протокол. Более того, у классов может одновременно быть и virtual table и witness table.

Article_MethodDipsatch_2
Witness Table
protocol Animal { func speak() } extension Animal { func hunt() { print("Животное охотится") } } class Dog: Animal { func speak() { print("Гав") } func hunt() { print("Собака рычит и охотится") } } let animal: Animal = Dog() animal.speak() // Гав // Если метод не объявлен в протоколе, то он не попадет в witness table. // По статической диспетчеризации будет использована базовая реализация метода. animal.hunt() // Животное охотится

Message Dispatch

Диспетчеризация сообщениями используется, когда метод вызывают через runtime Objective-C. Вызов происходит по имени (селектору), а не по полю в таблице, как у virtual table. Так работают наследники NSObject. Чтобы методы Swift были видны runtime Objective-C, их помечают @objc. Даже тогда вызов из Swift может идти через vtable. dynamic заставляет и этот вызов идти по селектору.

Селектор это уникальное имя метода в runtime. Это не String и не NSString. Два селектора сравнивают как два адреса. String сравнивали бы посимвольно, это медленнее.

In Objective-C, selector has two meanings. It can be used to refer simply to the name of a method when it’s used in a source-code message to an object. It also, though, refers to the unique identifier that replaces the name when the source code is compiled.
Selectors, The Objective-C Programming Language

Так работают:

  • методы с @objc dynamic
  • Target-Action: addTarget и #selector
  • KVO у свойств @objc dynamic

У класса-наследника NSObject хранится список методов. Каждый элемент списка содержит селектор и адрес тела метода. В список попадают только методы этого класса, адреса из родителя сюда не копируют, в отличие от virtual table. Также у каждого класса есть ссылка на класс-родитель.

В рантайме происходит обход этого списка и поиск нужного селектора для вызова. Если нужного селектора не нашлось, поиск продолжается в списке родителя. Нашли метод один раз: класс запоминает его в кэше. Следующий вызов с тем же селектором берёт адрес из кэша, без повторного обхода списка.

Article_MethodDispatch_3
Message Dispatch
// @objc dynamic class Loader: NSObject { @objc dynamic func reload() { print("перезагрузка") } } let loader = Loader() loader.reload() // Target-Action final class Screen: UIViewController { let button = UIButton(type: .system) override func viewDidLoad() { super.viewDidLoad() button.addTarget(self, action: #selector(didTap), for: .touchUpInside) } @objc func didTap() { print("клик") } } // KVO class Person: NSObject { @objc dynamic var name: String = "" } let person = Person() let observation = person.observe(\.name, options: [.new]) { _, change in print(change.newValue ?? "") } person.name = "Иван"