The slightly confusing bit for me was that this scheme conflates the concepts of visibility and dispatch.

In Swift 3 and earlier, dynamic also implied @objc. New in Swift 4, dynamic only means dynamic dispatch and says nothing about Objective-C visibility.

However, there’s no such thing as Swift dynamic dispatch; we only have the Objective-C runtime’s dynamic dispatch. That means you can’t have just dynamic and you must write @objc dynamic. So this is effectively the same situation as before, just made explicit.

That’s really all there is to it. But where’s the fun in that? How does it work under the hood? Can we start to unbox @objc and dynamic? 📦

performOcOperation has @objc, which means it should be visible to the Objective-C runtime. The compiler does some extra name transformation when it sees the string “Objc” in a name so I used “Oc” instead.

performDynamicOperation opts into dynamic dispatch. The method will also be visible from Objective-C.

What’s next, compile it down to machine code and run a binary diff? 😓

Under the Hood

“When in doubt, examine the machine code.”
— me

All right, we don’t have to go all the way down to machine code. But let’s go one level down to my favorite part of the Swift compiler layer cake: SIL.

Not sure if these labels should be reversed and assembly is the strawberry on top?

Note: If you want all the code in a gist and instructions on how to compile it, there’s a postscript at the end with all the details.

Function Implementations

The first thing to see is that the SIL code for all three functions is exactly the same. Maybe that’s not such a big surprise — each function just returns 42 and the only difference is in how the function might be called.

%2 — as the comment above this line suggests, this is the Int initializer that takes an integer literal.

%5 — apply %2 (that’s Int.init) with parameters %4 and %3, which are the literal 42 and the Int type.

return the newly created Int.

That seems like a lot of work to return 42 but there it is, in full SIL glory.

Again, performOcOperation and performDynamicOperation have the exact same implementation. So what’s different about them? 🔎

Different Ways to Dispatch

If you have an instance and want to call an instance method, how would you do it?

Calling a plain old function seems like a simple matter: find its address and jump to it. Instance methods in Swift can be just as simple. Similar to C++, Swift keeps a virtual call table or vtable for each named type.

At the end of the generated SIL, you’ll find a vtable for the ToObjcOrNotObjc class:

Notice the addition of .foreign to the method lookup as we’re now outside the native Swift world. The calling convention has also changed from plain old “method” to $@convention(objc_method).

In short:

dynamic methods don’t appear in the vtable.

At the call site, things go through the foreign-to-Swift Objective-C system.

Objective-C Visibility

At the SIL declaration level, all our functions performOperation, performOcOperation, and performDynamicOperation were declared with $@convention(method).

That’s OK for performOperation which is only callable from Swift. We just saw how performDynamicOperation goes through a foreign call system to get dynamic dispatch from Swift.

But performOcOperation and performDynamicOperation are also callable from Objective-C. How does that bridge over?

Thunks

The magic bit of glue here is a thunk. In the Swift to Objective-C world, this is an additional method callable from Objective-C. It’s a thin wrapper and all it needs to do is call through to the native Swift method.

Here’s what the thunk for performOcOperation looks like; I left in a tiny bit of mangled name so you can tell the functions apart — performOcOperationSiyF is the regular function and performOcOperationSiyFTo has the extra “To” at the end meaning @objc:

Notice the [thunk] and $@convention(objc_method) on the first few lines.

%3 — grabs a reference to the native Swift method.

%4 — calls the native Swift method.

Final return statement passes back the result.

If you migrated code from Swift 3 to Swift 4, you probably had to add @objc to several methods since that’s no longer inferred. One benefit to the new system mentioned in SE-0160 is smaller binaries and faster load time since there should be fewer thunks in existence.

Although this thunk is simple, you can imagine more complex ones when the function has arguments that need to be set up — converting Swift strings to NSString instances, etc.

Timing

As a final point of curiosity, I was thinking about performance. Is dynamic dispatch slower? If so, by how much?

I ran a hundred million iterations of calling each of performOperation, performOcOperation, and performDynamicOperation to see what kind of difference there is, and here’s what I saw on my 12" Macbook:

Time for 100000000x performOperation: 0.5983s
Time for 100000000x performOcOperation: 0.5911s
Time for 100000000x performDynamicOperation: 3.3750s

@objc doesn’t have much effect, but dynamic definitely has a cost!

You’ll see different results with compiler optimizations turned on and when you do more work than just return 42; as always your mileage may vary, so take these numbers as trivia and a starting point.

The Closing Brace

Here’s the least you need to remember:

@objc makes things visible to Objective-C code. You might need this for setting up target/action on buttons and gesture recognizers.

dynamic opts into dynamic dispatch. You might need this for KVO support or if you‘re doing method swizzling.

The only way to do dynamic dispatch currently is through the Objective-C runtime, so you must add @objc if you use dynamic.

As for thunks and vtables and SIL: they’re lurking one level down the Swift compiler layer cake, and I always enjoy knowing a bit more about what’s going on there. I’m by no means an expert but I’ll keep digging where I can. ⛏