Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
In software engineering, as with any industry largely staffed by savants, it’s common to have a few foibles.
My personal pet peeve is when people share sample code with a class that is *not marked final
*, despite it being a standard best practice.
We can argue for hours about whether sample code should strictly use best practices vs. keep constrained to relevant concepts. But one thing is clear: I am very fussy.
Today, we’re going to review how functions work in Swift. No, not the func
syntax. We’re going to learn how Swift executes function calls. This is called Method Dispatch, and it’s crucial for understanding the performance characteristics of your code.
We’re going broad and deep, covering:
The many flavours of method dispatch
What’s going on under the hood when your functions are compiled
How Swift calls your functions at runtime
How to make your code go faster
Building up a general intuition about how dispatch is working
By the end, I hope you will understand, if not share, my seething fury when somebody doesn’t mark a class final
.
Method Dispatch
In computer science, there are 2 broad types of “dispatch” with a clear trade-off:
Static dispatch, which is fast, but inflexible
Dynamic dispatch, which is slower, but more flexible
These can be individually broken down into sub-types, which form a 2-way hierarchy of speed and flexibility.
Inlining (fastest, not flexible)
Static dispatch
Table dispatch
Message dispatch (slowest, extremely flexible)
This hierarchy is due to levels of indirection. In layman’s terms, that means “the number of jumps needed to find and execute a function”:
Inlined code jumps zero times. It’s just there.
Statically dispatched code jumps just once to get to a function.
Table dispatched code has to jump twice — once to a table of function pointers, and once again to find the function itself.
Message dispatched code may jump around many times while traversing through data structures for the correct function.
Method dispatch makes Swift quite unique: Most languages support a couple of dispatch approaches, but Swift supports them all.
This is a double-edged sword: it gives engineers fine-grained control over the performance characteristics of their code; but also leads to many of the gotchas which can trip up less experienced Swifties (that’s what they call us, right?).
Static Dispatch (and friends)
Inlining
This is the fastest approach, and is actually not dispatch at all. Inlining is a compiler optimisation which literally replaces the call site of a function with the code from said function.
Generally, we don’t control this — the Swift compiler makes the decision about inlining function calls during its optimisation stages.
For example, say you had the following code:
// main.swift
func addOne(to num: Int) -> Int {
return num + 1
}
let twoPlusOne = addOne(to: 2)
If the compiler decides to inline this, the compiled Swift might be equivalent to this:
// main.swift (optimised)
let twoPlusOne = 2 + 1
Here, the call to the addOne
function is just replaced with the operation num + 1
, where num
is replaced with our argument, 2
.
Precomputing
Since this example uses hardcoded numbers, the compiler actually has all the information needed to calculate the result of addOne
at compile-time. This means the compiler can perform a further optimisation:
As well as inlining the function call; the return value can be precomputed, resulting in code that looks like this:
// main.swift (optimised even more)
let twoPlusOne = 3
Precomputing is the ultimate optimisation; since we aren’t even executing code at this point — the result of our function call is known at compile time, and so no work needs to happen in our millions (hopefully) of users’ devices at runtime.
Swift Intermediate Language
Before compiling your code down to machine language, the Swift compiler converts it into Swift Intermediate Language (SIL), where it runs through many optimisation passes.
These arcane hieroglyphs allow us to see the optimisations in person:
// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
// 1
alloc_global @$s4main10twoPlusOneSivp
// 2
%3 = global_addr @$s4main10twoPlusOneSivp
// 3
%4 = integer_literal $Builtin.Int64, 3
// 4
%5 = struct $Int (%4 : $Builtin.Int64)
// 5
store %5 to %3 : $*Int
// 6
%7 = integer_literal $Builtin.Int32, 0
%8 = struct $Int32 (%7 : $Builtin.Int32)
return %8 : $Int32
I omitted most of the code for brevity (4 lines of Swift turns into 78 lines of SIL!), but we can see inlining in action:
Memory is allocated for the
twoPlusOne
property.The pointer address of our
twoPlusOne
property is assigned.This is where the magic happens: an integer literal for
3
is precomputed and inlined, avoiding a method call entirely.This value converted into an
Int
struct from the Standard Library.This
Int
is stored at%3
, the memory address oftwoPlusOne
.You’ll usually see these lines at the end of a
main()
function— this is simply exiting the program with code0
(i.e. without an error).
If you want to see the SIL for yourself, convert use the command swiftc -emit-sil -O main.swift > sil.txt
.
The -O
tells the compiler to run optimisations for speed, which includes inlining. -Osize
in contrast makes the compiler less likely to inline code; since inlining a function in multiple places can increase binary size.
SwiftRocks has a great article if you want more of a deep-dive on inlining and the undocumented
@inline
attribute.
Why is inlining faster?
While lines of SIL code do not map 1:1 onto ARM assembly instructions, it is pretty intuitive that less SIL code suggests that a program will execute faster. But to truly peer behind the veil, we need to touch the metal: what is happening on the CPU when you call a function?
There is Overhead each time you invoke a function. The CPU needs to cache data on its registers, jump the instruction pointer to a new memory address, and restore state after execution.
An executing Swift binary lives primarily in a chunky
TEXT
file stored in memory. Segments of the code currently running are copied to the CPU Caches, which the CPU can access 100x faster than RAM. An expensive cache misshappens if a function needs to be loaded in from memory when called.CPUs utilise Pipelining to handle multiple instructions simultaneously. When it needs to wait for a new function to be loaded in from RAM, the instruction pipeline may be disrupted, stalled, or even invalidated entirely.
The CPU applies Branch Prediction to estimate which code paths are likely to run next, allowing it to fill the pipeline with the most likely paths (and even look ahead with predictive execution). Jumps to function calls can easily disrupt a CPU’s ability to predict outcomes.
Due to all these factors, inlining and pre-computation are powerful tools in the Swift Compiler’s arsenal for optimising your code for pure speed.
If you found this segment interesting, I’ve written extensively on registers, CPU caches, and pipelining in my magnum opus, Through the Ages: Apple CPU Architecture.
Static Dispatch
This is also known as direct dispatch or occasionally compile-time dispatch. These names all describe what’s going on:
static implies that the location of the function in memory is fixed…
…and knowable at compile-time.
Therefore, only one jump is required to find and execute the function, directly to the memory address of the function.
static
functions in Swift, as well as functions on enum
s and struct
s, always use static dispatch. The compiled machine code of these functions is stored at a known address in memory when a Swift program runs.
This deterministic nature of static dispatch is what enables the compiler to run optimisations such as inlining and pre-computation.
Let’s see this in action with a very basic struct, with another addOne
funtion:
// main.swift
struct Adder {
func addOne(to int: Int) -> Int {
return int + 1
}
}
let threePlusOne = Adder().addOne(to: 3)
Let’s generate the Swift Intermediate Language for this code and see what’s going on under the hood of the compiler.
Again, I’ll massively cut down the 97 lines of generated SIL for this 7-line
main.swift
file to avoid information overload.
Let’s see what became of our main function:
// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
// 1
alloc_global @$s4main12threePlusOneSivp
%3 = global_addr @$s4main12threePlusOneSivp : $*Int
// 2
%4 = metatype $@thin Adder.Type
%5 = function_ref @$s4main5AdderVACycfC : $@convention(method) (@thin Adder.Type) -> Adder
%6 = apply %5(%4) : $@convention(method) (@thin Adder.Type) -> Adder
// 3
%7 = integer_literal $Builtin.Int64, 3
%8 = struct $Int (%7 : $Builtin.Int64)
// 4
%9 = function_ref @$s4main5AdderV6addOne2toS2i_tF : $@convention(method) (Int, Adder) -> Int
%10 = apply %9(%8, %6) : $@convention(method) (Int, Adder) -> Int
store %10 to %3 : $*Int
Memory is allocated for the
threePlusOne
property.The function call for the Adder
struct
’sinit
function is called. You thought the initialiser was implicit? It was, until the Swift code actually compiles and generates it!apply
is the SIL instruction for calling a function, taking%4
(the type) as an argument for%5
(the function).Next, the integer literal for our function argument — that is, the number
3
— is instantiated. First a Builtin Literal is called, then anInt
is initialised.Finally, our
addOne
function is called; creating a function pointerfunction_ref
, and passing the arguments created before: theInt
and theAdder
.
The calling convention of SIL is very similar to that of Python where self
, the instance, is explicitly passed to the call site of its methods. This is because the methods on a type are shared between all instances in memory. Therefore, a reference to the instance is required to access or mutate any properties.
The definition for our addOne
function is here as well:
// Adder.addOne(to:)
sil hidden @$s4main5AdderV6addOne2toS2i_tF : $@convention(method) (Int, Adder) -> Int {
// %0 "int"
//...
// 1
%4 = integer_literal $Builtin.Int64, 1
%5 = struct_extract %0 : $Int, #Int._value
// ...
// 2
%7 = builtin "sadd_with_overflow_Int64"(%5 : $Builtin.Int64, %4 : $Builtin.Int64, %6 : $Builtin.Int1) : $(Builtin.Int64, Builtin.Int1)
// ...
// 3
cond_fail %9 : $Builtin.Int1, "arithmetic overflow"
%11 = struct $Int (%8 : $Builtin.Int64)
return %11 : $Int
}
An
integer_literal
is declared using1
, and the integer value of the input argument is extracted.This is where the magic happens — the actual functionality of the
Int.+
function is inlined here using thebuiltin
implementation.There is some nifty error-handling that detects arithmetic overflow (i.e. values over 2⁶³-1); and the result
Int
is instantiated and returned.
I won’t go into more detail here, but the Int.init(_builtinIntegerLiteral:)
initialiser and the Int.+ infix(_:_:)
functions are also both defined in the SIL.
Not sure what is meant by
Builtin
? To find out, check out my magnum opi; COW2LLVM: The isKnownUniquelyReferenced Deep-Dive and 2 Minute Tips: The Dark Secrets of Bools.
When compiling this SIL with optimisations, the addOne
function itself is inlined straight to the call site; and the Int.init()
and Int.+
functions disappear entirely.
The Swift compiler collapses entire chains of statically-dispatched function calls inline to extinguish many expensive function calls at once. Such is the power of direct dispatch.
Dynamic Dispatch
This is also known as table dispatch, or sometimes runtime dispatch. Some of these words are straightforward — the function we dispatch to is dynamically chosen at runtime.
Table dispatch isn’t as obvious, but divulges an implementation detail: a table of pointers. This table is critical to implement polymorphism — the ability of a single type to have multiple forms.
Swift actually implements two flavours of table dispatch: virtual tables for class hierarchies, and witness tables for protocols.
Virtual Table Dispatch
Consider a very basic class hierarchy:
// main.swift
class Cat {
func cry() {
print("Meow")
}
func eat() {
print("Purr")
}
}
final class Lion: Cat { // see, using `final` is not that bloody hard
override func cry() {
print("Roar")
}
}
Here, the subclassing relationship implies that a Lion
is “a kind of cat”. If this code was part of an open-source Animals
package, and marked open
*, we could import it and subclass Cat
ourselves, with a custom implementation of cry()
.
Therefore, if you use a Cat
subclass in your codebase, it might roar, since Lion
is a possible subclass of Cat
. Swift also needs to handle other possible implementations of cry()
for any other subclasses.
Since our .swift
files are compiled independently**, the Swift compiler can’t be sure which implementation is going to be used where. This information is only available at runtime — until the object is actually created, we don’t know whether we’re dealing with a bog-standard cat or the king of the jungle***.
The virtual table isn’t magic at all.
It’s simply a list, created at compile-time on each subclass, which maps each function to their implementation in memory.
If Lion
overrides cry()
, then the table points at the instructions defined on Lion.cry()
.
If it doesn’t override eat()
, then the virtual table for Lion
will point at the instructions defined in the Cat
superclass.
// vtable for Cat
| Function Name | Pointer Address |
|---------------|-----------------------|
| cry() | 0x1000 |
| eat() | 0x1008 |
// vtable for Lion
| Function Name | Pointer Address |
|---------------|-----------------------|
| cry() | 0x2000 [override] |
| eat() | 0x1008 [inherited] |
This is the indirection I was talking about in the introduction.
Table dispatch is slower than direct dispatch because at runtime, to dynamically dispatch to a function, Swift first needs to:
Jump to the virtual table stored in the subclass type metadata.
Pick out the correct function pointer.
Jump again, to the function’s memory address.
*Want to learn more about access modifiers such as
open
? Check out Access Control Like A Boss.**.swift files compile independently when whole-module optimisation is not active — I’ll go into more detail in Making Your Code Go Faster below.
***Shouldn’t it be “king of the savannah”?
Protocol Witness Tables
Protocols allow developers to add polymorphism to types through composition, even to value types like structs or enums. Protocol methods are dispatched via Protocol Witness Tables.
The mechanism for these is the same as virtual tables: Protocol-conforming types contain metadata (stored in an existential container*), which includes a pointer to their witness table, which is itself a table of function pointers.
When executing a function on a protocol type, Swift inspects the existential container, looks up the witness table, and dispatches to the memory address of the function to execute.
This only happens if the type you’re dispatching to is an abstract protocol type. If you specify the concrete type of something that conforms to the protocol, then the specific implementation of the code is known at compile-time, and can therefore be statically dispatched. I’ll go into this in more detail in Building Up An Intuition below.
*Existential containers are an underlying implementation detail of protocols. To understand these in more detail, check out the in-depth essential WWDC talk, Understanding Swift Performance (2016).
When do you use abstract types?
Frequently, for example when we use dependency injection, we only specify the protocol — the interface our dependency conforms to — without a concrete type.
Other times, we might have a Collection containing various protocol-conforming objects we want to iterate over. In these cases, method dispatch is via the witness table.
The term witness table is borrowed from constructive logic, where proofs serve as witnesses for propositions. In my opinion, though, this feels like a post-hoc justification — they had already used the term “virtual tables” for dynamic dispatch with subclasses. I reckon Lattner just wanted a different term to distinguish the same concept as virtual tables, in a slightly different context.
Table Dispatch in Swift Intermediate Language
As mentioned, there are two main ways to invoke dynamic dispatch in Swift. Firstly, let’s look at the vanilla virtual-table dispatch you might get in a language like Java or C++.
// main.swift
class Incrementer {
func increment(_ int: Int) -> Int {
return int + 1
}
func deincrement(_ int: Int) -> Int {
return int - 1
}
}
class DoubleIncrementer: Incrementer {
override func increment(_ int: Int) -> Int {
return int + 2
}
}
let threePlusTwo = DoubleIncrementer().increment(3)
We can see the virtual tables (A.K.A. vtable
s ) created in the SIL. DoubleIncrementer
implements both methods, but only overrides one with a pointer to its own implementation:
sil_vtable Incrementer {
#Incrementer.increment: (Incrementer) -> (Int) -> Int : @$s4main11IncrementerC9incrementyS2iF // Incrementer.increment(_:)
#Incrementer.deincrement: (Incrementer) -> (Int) -> Int : @$s4main11IncrementerC11deincrementyS2iF // Incrementer.deincrement(_:)
}
sil_vtable DoubleIncrementer {
#Incrementer.increment: (Incrementer) -> (Int) -> Int : @$s4main17DoubleIncrementerC9incrementyS2iF [override] // DoubleIncrementer.increment(_:)
#Incrementer.deincrement: (Incrementer) -> (Int) -> Int : @$s4main11IncrementerC11deincrementyS2iF [inherited] // Incrementer.deincrement(_:)
}
Let’s see how it looks when we use a protocol:
// main.swift
protocol Incrementer {
func increment(_ int: Int) -> Int
}
struct IncrementerImpl: Incrementer {
func increment(_ int: Int) -> Int {
return int + 1
}
}
let fourPlusOne = IncrementerImpl().increment(4)
This time, we see a witness table (sil_witness_table
) in the SIL:
sil_witness_table hidden IncrementerImpl: Incrementer module main {
method #Incrementer.increment: <Self where Self : Incrementer> (Self) -> (Int) -> Int : @$s4main15IncrementerImplVAA0B0A2aDP9incrementyS2iFTW // protocol witness for Incrementer.increment(_:) in conformance IncrementerImpl
}
In both of these very simple examples, the compiler actually ended up statically dispatching the main()
function straight to the method implementation of increment()
, bypassing the dispatch tables.
Much like an overzealous butler, the compiler won’t give you what you want; it’ll give you what it thinks you need…
If you compile with optimisations, Swift drops the functions entirely and produces precomputed results at the call site!
Message Dispatch
Message dispatch is the most dynamic dispatch approach in Swift’s repertoire of dynamic dispatch approaches. It’s so dynamic, the implementation of a method can be changed at runtime through swizzling. It’s so dynamic, it doesn’t actually even use Swift — it lives in the Objective-C runtime library.
Message-dispatched function invocations are dispatched using the ObjC runtime’s objc_msgSend function. Instances of Objective-C classes have an isa
pointer which points to the “class object” — the implementation of the type in memory.
objc_msgSend
follows isa
to the class, then inspects its table of method selectors. If the method is found, it gets executed. If not, then the runtime follows the super
pointer to the table in the superclass. If the method is not found, then the runtime continues to iterates through the object hierarchy tree, until either the method is found or NSObject
(ObjC’s root object) is reached.
This table of method selectors is implemented as a message passing dictionary, which is mutable at runtime. This is how ObjC implements its famous method swizzling.
The ObjC runtime caches the memory addresses of methods on the class as they get used. Calling a cached method is almost as fast as a regular table-dispatched function call, making message dispatch fairly fast once the program has been running a while and the caches are “warmed up”.
Look. If you want my honest opinion, I don’t think anybody implementing a language like Swift today would have used message dispatch — just look at Kotlin. Message dispatch is somewhat of a holdover from the fact that virtually all of Apple’s frameworks have been implemented in Objective-C since time immemorial, including Core Data, UIKit, and Swift’s KVO.
Message Dispatch in SIL
To invoke message dispatch in Swift, you need two keywords:
The
@objc
attribute, which tells the compiler to make a class, property, or method available to the Objective-C runtime.The
dynamic
keyword, which tells the compiler to invoke the property or method via message dispatch.
Let’s write our final main.swift
:
import Foundation
class Incrementer {
@objc dynamic func increment(_ int: Int) -> Int {
return int + 1
}
}
let fourPlusOne = Incrementer().increment(4)
And run swiftc -emit-sil main.swift > sil.txt
:
// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
// ...
// 1
%9 = objc_method %6 : $Incrementer, #Incrementer.increment!foreign : (Incrementer) -> (Int) -> Int, $@convention(objc_method) (Int, Incrementer) -> Int // user: %10
%10 = apply %9(%8, %6) : $@convention(objc_method) (Int, Incrementer) -> Int // user: %12
// ...
}
// Incrementer.increment(_:)
sil hidden @$s4main11IncrementerC9incrementyS2iF : $@convention(method) (Int, @guaranteed Incrementer) -> Int {
// 2
// ...
}
// @objc Incrementer.increment(_:)
sil private [thunk] @$s4main11IncrementerC9incrementyS2iFTo : $@convention(objc_method) (Int, Incrementer) -> Int {
bb0(%0 : $Int, %1 : $Incrementer):
// ...
// 3
%3 = function_ref @$s4main11IncrementerC9incrementyS2iF : $@convention(method) (Int, @guaranteed Incrementer) -> Int // user: %4
%4 = apply %3(%0, %1) : $@convention(method) (Int, @guaranteed Incrementer) -> Int // user: %6
strong_release %1 : $Incrementer // id: %5
return %4 : $Int // id: %6
}
// 4
sil_vtable Incrementer {
#Incrementer.init!allocator: (Incrementer.Type) -> () -> Incrementer : @$s4main11IncrementerCACycfC // Incrementer.__allocating_init()
#Incrementer.deinit!deallocator: @$s4main11IncrementerCfD // Incrementer.__deallocating_deinit
}
Here, the
objc_method
onIncrementer
is invoked in ourmain
function. Note the#Incrementer.increment!foreign
to denote that the method is using something outside of native Swift.When you have an
@objc
method implemented with Swift, both a Swift flavour and an Objective-C flavour of the method are emitted in SIL. This redacted SIL code is identical to the statically-dispatched logic we looked at earlier (since we will call into it!).Here, in the
@objc Incrementer.increment(_:)
,[thunk]
lets the Objective-C runtime statically dispatch to the Swift implementation of the method.* Using a separate ObjC function like this allows Swift-native code to call straight into the Swift version straight away (and eschew slower message dispatch).Methods marked
dynamic
do not appear in thev_table
, since regular table dispatch is not used to resolve the method call.
Message Dispatch. Huh. Yeah. What is it good for?
Message dispatch is certainly not the hammer you want to use on every programming nail, but it absolutely shines in the right use case. Take Realm for example — yes, I know it’s called “Atlas Device SDKs” now, but that’s also the silliest rebrand I’ve heard in my life.
Atlas Device SDKs, please sponsor my Medium 🙏
Using Realm on iOS, you need to mark the properties on your database objects @objc dynamic
. This is to expose them to the Objective-C runtime, enable message dispatch on the properties, and hence enable key-value observation!
Under the hood, this KVO uses method swizzling to replace the getter and setter of a property with custom read/write methods. This in turn allowed database objects to dynamically update whenever the underlying data was modified.
Making Your Code Go Faster
This is all really nice theory. Well done, me.
But outside of impressing your spouse with your stupendous comp-sci knowledge, theory isn’t that useful unless you apply it.
Let’s discuss how your knowledge of method dispatch can help your own code run faster and more efficiently.
Reducing Dynamic Dispatch
As demonstrated above, the compiler works hard to optimise your Swift code before anything runs.
From the Swift repository:
If the static type of the class instance is known, or the method is known
to be final, then the instruction is a candidate for devirtualization
optimization.A devirtualization pass can consult the module’s
VTables
to find the SIL function that implements the method and promote the
instruction to a staticfunction_ref
.
Outside the specific context of trying to demonstrate bloody dispatch methods for a blog post*, this is generally considered pretty useful. There are several things we can do as developers to help the compiler out, and speed up our Swift code.
*I had to muck around with
swiftc -emit-sil
for a while to stop it optimising away all the dynamic method calls.
Since I am now one with the Swift compiler; I was unable to resist inlining this information here instead of just linking you to the Swift docs.
Use
final
wherever possible — this allows the compiler to convert methods onfinal
classes and methods to use static dispatch (and possibly inlining and pre-computation) instead of virtual table dispatch.Use
private
wherever possible — this guarantees that a method isn’t polymorphic, allowing the compiler to inferfinal
on a method automatically.Enable Whole Module Optimisation (this is on by default) so the compiler compiles a module’s files all together (rather than each
.swift
file individually). This can giveinternal
classes and methods the inferred-as-finaltreatment.
Performance Characteristics
The actual performance impact of the dynamic dispatch is, usually, fairly small — the additional layer of function call indirection is only a few extra instructions, or clock cycles.
There are, however, two big ways in which dynamic dispatch has an impact on runtime performance:
The compiler loses the ability to perform optimisations on the code, such as by inlining or precomputing.
Indirection can increase the likelihood of a cache miss. Since the location of a function isn’t known at runtime, instead of the CPU reading its instructions from the L1 cache (1ns), your program may need to fetch the function code from RAM (100ns).
Why can’t we just work out all the concrete types at compile-time?
I’m glad you asked.
The whole point of polymorphism is that concrete types aren’t fully known at compile-time, so runtime behaviour can vary. This allows us to write flexible code. But when we are bounded within a region of known access control, we canknow all this at compile-time.
Consider the guidance the Swift team gives us about reducing dynamic dispatch — final
, private
, and whole-module optimisation.
This gives the compiler more information.
Therefore it can work out whether a type is knowable at compile-time, and therefore whether it can be converted to static dispatch and all its consequent optimisations.
Building up an intuition
Due to its support of all the dispatch methods, Swift has many unintuitive gotchas about how certain types of method are dispatched.
When you understand the underlying mechanisms of static and dynamic dispatch — specifically, the information which is available to the compiler — we can build up an intuition for these quirks.
The canonical example
Protocols are commonly invoked to demonstrate this quirk to the juniors on your team. With protocols, you can impact dispatch behaviour via type declarations — for example, say you had a protocol, with some default implementations, and a type conforming to this protocol:
protocol Animal {
func cry() -> String
}
extension Animal {
func cry() -> String {
return "..."
}
func sayHello() -> String {
return "Hello"
}
}
class Cat: Animal {
func cry() -> String {
return "Meow"
}
func sayHello() -> String {
return "Purr"
}
}
Now, when you call these functions, the type declaration you use affects whether you get static or dynamic dispatch.
// 1
var animal: Animal?
animal = Cat()
// 2
animal?.cry() // meow
// 3
animal?.sayHello() // hello
// 4
var cat: Cat = Cat()
// 5
cat.cry() // meow
// 6
cat.sayHello() // purr
Feel free to copy this into a Swift playground and see for yourself!
When we declare an object as an
Animal
type, the compiler knows to expect dynamic dispatch, since the type of the animal could be mutated or set elsewhere.cry()
, which is a protocol requirement, gets dispatched via a witness table to the cat’s implementation. Protocol requirement methods will always use asil_witness_table
because the implementation isn’t necessarily definitive at compile-time*.When calling
sayHello()
on the animal, this is statically dispatched to the protocol extension’s implementation because non-requirement methods don’t use a witness table, and the type is known to be anAnimal
.We can instantiate a
Cat
object, which conforms to the Animal protocol but has a definitive concrete type. This gives the compiler a ton of information which will allow it to optimise things — if the class was final, or whole-module optimisation was turned on, then the methods onCat
will be directly dispatched. Otherwise, they will be dynamically dispatched via a witness table.When calling the
cry()
method oncat
, the Swift Runtime doesn’t consult the protocol witness table at all, since the concrete type is known. Therefore, it meows (either statically or dynamically, depending on how your project is set up).Calling
sayHello()
is similarly executed by the runtime on the concrete cat type, either directly or through table dispatch.
*In this instance, the compiler can know the concrete type of Animal, however Swift does not optimise this away — optimisation from dynamic to static is great, but not when it actually changes the implementation of a method.
How to intuit dispatch
At this point in the deep-dive, most contributors to Swift method dispatch discourse like to present a table with the types of entity (protocol, class, final class, extension, etc) and the type of dispatch it uses (conveniently ignoring the existence of WMO).
However, in my opinion it’s fundamentally a little unhelpful to attempt to memorise the various individual gotchas of the Swift dispatch system. It’s exhausting, and more importantly, the compiler is optimising pretty hard, so the table would just be full of asterisks.
The trick? You simply need to think about whether the memory location of the function’s is knowable at compile-time.
struct
methods? Obviously static.
class
methods? These can be made static, but only if the compiler can know the type at runtime.
open class
methods? This probably has to use table dispatch to find classes in other modules.
protocol
requirement methods? No, this always needs to use a witness table, even if the type is knowable, so that execution is consistent.
protocol extension
methods that aren’t requirements? These just live with the protocol in memory, so can be directly dispatched.
Again, I’m trying *not* to give an exhaustive list, because now you have the power to work out most of these instances using dispatch theory.
Conclusion
Look, I really wanted to show you a cool sample project that showed all these dispatch performances in detail — setting up some structs
, classes
, and dynamic
functions, then running some simple addition functions 1 million times each.
But the compiler kept inlining and precomputing everything!
After some time spent fighting these optimisations, attempting to get to grips with@inline(never)
, and fighting my instincts to clean up the code, I admitted defeat.
Remember my plight the next time you run into a gotcha, some unexpected behaviour, with Swift, and just remember the compiler is trying its darnedest.
If you enjoyed this deep dive, and want more, take a journey deep into the compiler with COW2LLVM: The isKnownUniquelyReferenced Deep-Dive.
Thanks for great writing, below is my understanding, pls let me know if I'm wrong 🙏
1. inline / precomputing (dispatch)
- execute directly, no jump
- well know info at compile time
- e.g: simple struct, enum definition
2. static dispatch:
- 1 jump to execute address
- execute memory address is knowable at compile time
3. dynamic dispatch (virtual table / witness table dispatch)
- inheritance / polymorphism (method override, protocol conforms …)
- method execute address is stored in function table
- 2 jumps when execute: (1) jumps to the function table, (2) access `vTable[method_name]` to get the execute access
4. message dispatch
- powered by Objective-C runtime , pure swift don’t have message dispatch
- using `objc_msgSend` to determine the implementation of a given `selector` ,
- complier search through class (NSObject subclass) hierarchy bottom up,
- each class can provide the implementation at runtime using several method level: `resolveInstanceMethod`, `forwardingTargetForSelector` , `forwardInvocation` (similar for class method)