Dispatching Methods in Swift
In object-oriented languages, like Swift, method dispatch is the mechanism that tells the CPU where in memory the executable code for a particular method call is located.
While this is not something that’s given much thought everyday it can help understand specific performance issues and gain insights on how the compiler works.
Compiled languages have three primary methods of dispatch: static dispatch, table dispatch and message dispatch.
Static, direct or compile-time dispatch
If a call is statically dispatched the compiler is able to locate at compile time where the instructions for that method are located. The system can jump directly to the memory address of the call to perform the operation.
This behavior results in fast execution while also allowing the compiler to perform different kinds of optimizations like inlining or function devirtualization1.
Value types use this dispatch method and also methods declared on extensions.
Table or dynamic dispatch
Each of the methods declared on a class are associated with a virtual method table. A vtable defines an array of function pointers to the implementation corresponding to that class.
This table is built at compile time as for every class it needs to determine if it has been subclassed so the table can contain which implementation of a class to call.
Message dispatch
This mechanism is provided by Objective-C. Every time an Objective-C method is called the invocation is passed to objc_msgSend
which handles the lookups as it starts with the given class and iterates through the class hierarchy to pick the correct implementation. The cost of resolving the appropriate implementation can add some overhead making it a little expensive as the lookup performance is guarded by caching mechanism.
Opposed to table dispatch, the message passing dictionary could be modified at runtime, making the program behavior be adjusted while running. This is known as swizzling.
To be able to avoid Swift’s compiler to perform optimizations that avoid the use of method interception there are a couple of things you can do to enable message dispatch: Extending NSObject or using the objc
keyword or adding the dynamic attribute to a function.
Swift Compile Process
To better understand how different types of dispatches are reflected on a program we need to better understand how Swift compilation works at a high level.
- Swift code is fed to a Swift Front End that translates the source code into a high-level platform agnostic SIL (Swift Intermediate Language),
swiftc --emit-sil
- A SIL Optimizer takes the SIL and optimises it on a high-level of abstraction and creates an output in the IR (Intermediate Representation) format, which is low-level yet platform agnostic,
swiftc --emit-ir
. - Then, IR is optimized (LLVM IR Optimizer).
- The last phase receives the optimized IR and uses a Code Generator to generate a assembly code,
swiftc -S
.
To put all of the above into practice let’s analyse how the different dispatch strategies are manifested on the SIL output for different types in Swift.
As a sample, a vtable has the next format,
sil_vtable A {
#A.foo: (A) -> () -> () : @$s4main1AC3fooyyF // A.foo()
#A.init!allocator: (A.Type) -> () -> A : @$s4main1ACACycfC // A.__allocating_init()
#A.deinit!deallocator: @$s4main1ACfD // A.__deallocating_deinit
}
The vtable above can be defined as a dictionary that maps method names to their implementations so the program knows where in memory a specific method implementation is located.
Let’s see how the selection of different types or keywords affect the SIL output with different code snippets.
Classes
class A {
func foo() { }
func bar() { }
}
class B: A {
override func foo() { }
func bor() {}
}
final class C: A {
override func foo() { }
func baz() { }
}
sil_vtable A {
#A.foo: (A) -> () -> () : @$s4main1AC3fooyyF // A.foo()
#A.bar: (A) -> () -> () : @$s4main1AC3baryyF // A.bar()
#A.init!allocator: (A.Type) -> () -> A : @$s4main1ACACycfC // A.__allocating_init()
#A.deinit!deallocator: @$s4main1ACfD // A.__deallocating_deinit
}
sil_vtable B {
#A.foo: (A) -> () -> () : @$s4main1BC3fooyyF [override] // B.foo()
#A.bar: (A) -> () -> () : @$s4main1AC3baryyF [inherited] // A.bar()
#A.init!allocator: (A.Type) -> () -> A : @$s4main1BCACycfC [override] // B.__allocating_init()
#B.bor: (B) -> () -> () : @$s4main1BC3boryyF // B.bor()
#B.deinit!deallocator: @$s4main1BCfD // B.__deallocating_deinit
}
sil_vtable C {
#A.foo: (A) -> () -> () : @$s4main1CC3fooyyF [override] // C.foo()
#A.bar: (A) -> () -> () : @$s4main1AC3baryyF [inherited] // A.bar()
#A.init!allocator: (A.Type) -> () -> A : @$s4main1CCACycfC [override] // C.__allocating_init()
#C.deinit!deallocator: @$s4main1CCfD // C.__deallocating_deinit
}
Class A
defines both foo
and bar
methods. Both are reflected on the vtable as they can be inherited by a subclass and they have to be referenced at runtime.
A B
class inherits from A
while only overriding foo
and also defining a new bor
method. SIL’s table for B
reflects the new bor
method while also displaying how foo
and bar
have been defined from A
.
Lastly, C
has the same behavior described in B
for both foo
and bar
. And as it is marked as final
the vtable will not have an entry for C.baz
as it cannot be overriden.
Overriding methods in extensions
import Foundation
class A {
@objc dynamic func foo() { }
func bar() { }
}
class B: A { }
extension B {
@objc dynamic override func foo() { }
}
Overriding any of the methods defined in A
in a subclass’ extension like B
it is not allowed by the compiler. This behavior is not supported as non-objc properties and methods cannot be overriden in extensions.
To be able to make this work methods or properties need to be declared as both @objc
and dynamic
so the compiler will use message dispatch allowing methods and properties to be overriden in an extension.
sil_vtable A {
#A.bar: (A) -> () -> () : @$s4main1AC3baryyF // A.bar()
#A.init!allocator: (A.Type) -> () -> A : @$s4main1ACACycfC // A.__allocating_init()
#A.deinit!deallocator: @$s4main1ACfD // A.__deallocating_deinit
}
sil_vtable B {
#A.bar: (A) -> () -> () : @$s4main1AC3baryyF [inherited] // A.bar()
#A.init!allocator: (A.Type) -> () -> A : @$s4main1BCACycfC [override] // B.__allocating_init()
#B.deinit!deallocator: @$s4main1BCfD // B.__deallocating_deinit
}
The dynamic
keyword enables the possibility of overriding a declaration on an extension and the resulting SIL output is the same as before. It also enforces message dispatch for that call.
Value types
As value types, like structures, do not support inheritance the method dispatch of choice is static dispatch. Definitions of value types are not going to be reflected directly on the vtable of the SIL output.
Protocols
The usage of protocols introduce a new concept visible in the SIL table, a witness table.
protocol Parentable {
func foo()
}
class A: Parentable {
func foo() { }
}
struct B: Parentable {
func foo() { }
}
sil_vtable A {
#A.foo: (A) -> () -> ()
#A.init!allocator: (A.Type) -> () -> A
#A.deinit!deallocator
}
sil_witness_table hidden A: Parentable module main {
method #Parentable.foo: <Self where Self : Parentable> (Self) -> () -> ()
}
sil_witness_table hidden B: Parentable module main {
method #Parentable.foo: <Self where Self : Parentable> (Self) -> () -> ()
}
A witness table is emitted for every declared explicit conformance. For this sample two witness tables can be seen on the SIL output: one for the class A
and one for the structure B
.
Couple of extra things
final
keywords enables static dispatch on a method defined in a class. This keyword removes the possibility of any dynamic behavior while also hiding the method from the Objective-C runtime not generating a selector.
dynamic
enables message dispatch on a method defined in a class while also making it available to the Objective-C runtime. As seen before, the dynamic
keyword can be used to allow methods declared in extensions to be overriden.
@objc
& @nonobjc
alter how the method is seen by the Objective-C runtime. Generally, @objc
is used to namespace the selector and it doesn’t alter the dispatch selection method, it just makes it available to the Objective-C runtime. @nonobjc
does alter the dispatch selection as it can be used to disable message dispatch since it does not add the method to the Objective-C runtime which message dispatch relies on.
- The compiler decides, at compile time, which function should be called so it can produce a direct (or static) call to that function or even inline it. [return]