The @objc
attribute controls visibility of Swift bits from Objective-C. It’s back under the spotlight with Swift 4 and more specifically the changes in SE-0160, Limiting @objc inference.
What does @objc
mean? What about dynamic
? How do they work? And what’s different in the post-Swift 4 world?
This article will be a bit different — we’ll compile a simple bit of code and inspect it rather than look at something like Objective-C runtime source code.
Semi-Permeable Membranes
Way back in 2015 I gave a talk titled Switching Your Brain to Swift. Swift 2 was in beta and mixed codebases were all around us.
I used the analogy of a semi-permeable membrane — things make it across the language divide from Objective-C to Swift very easily, but not as easily from Swift to Objective-C.
There are two keywords to keep in mind when dealing with interoperability:
@objc
means you want your Swift code (class, method, property, etc.) to be visible from Objective-C.dynamic
means you want to use Objective-C dynamic dispatch.
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
? 📦
Three Ways to Decorate
Here’s what I started with to test three ways to decorate a function:
class ToObjcOrNotObjc {
func performOperation() -> Int {
return 42
}
@objc func performOcOperation() -> Int {
return 42
}
@objc dynamic func performDynamicOperation() -> Int {
return 42
}
}
performOperation
is the plain undecorated Swift way to do things.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.
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.
I’ve de-mangled some of the names to help readability:
sil hidden @performOperation : $@convention(method)
(@guaranteed ToObjcOrNotObjc) -> Int {
debug_value %0 : $ToObjcOrNotObjc, let, name "self", argno 1
// function_ref Int.init(_builtinIntegerLiteral:)
%2 = function_ref @_i2048_builtinIntegerLiteral : $@convention(method)
(Builtin.Int2048, @thin Int.Type) -> Int
%3 = metatype $@thin Int.Type
%4 = integer_literal $Builtin.Int2048, 42
%5 = apply %2(%4, %3) : $@convention(method)
(Builtin.Int2048, @thin Int.Type) -> Int
return %5 : $Int
} // end sil function 'performOperation'
Let’s focus on a few lines to warm up our SIL reading skills:
%2
— as the comment above this line suggests, this is theInt
initializer that takes an integer literal.%5
— apply%2
(that’sInt.init
) with parameters%4
and%3
, which are the literal42
and theInt
type.return
the newly createdInt
.
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:
sil_vtable ToObjcOrNotObjc {
#ToObjcOrNotObjc.performOcOperation!1: (ToObjcOrNotObjc) -> () -> Int : _ToObjcOrNot_performOcOperationSiyF
#ToObjcOrNotObjc.performOperation!1: (ToObjcOrNotObjc) -> () -> Int : _ToObjcOrNot_performOperationSiyF
#ToObjcOrNotObjc.init!initializer.1: (ToObjcOrNotObjc.Type) -> () -> ToObjcOrNotObjc : _ToObjcOrNotC0CACycfc
#ToObjcOrNotObjc.deinit!deallocator: _ToObjcOrNotC0CfD // ToObjcOrNotObjc.__deallocating_deinit
}
You’ll see four instance methods here in the vtable:
- performOcOperation
- performOperation
- init
- deinit
That gives us a hint on how those methods will be called. But notice performDynamicOperation
is nowhere to be seen! 🔬
Standard Dispatch
We’ve looked at the method implementations and the vtable. How do things look at the call site?
Consider this bit of Swift:
let q = ToObjcOrNotObjc()
q.performOperation()
q.performOcOperation()
q.performDynamicOperation()
If we compile that down to SIL, we can see side-by-side what the three calls look like:
// call to performOperation()
%5 = class_method %4 : $ToObjcOrNotObjc, #ToObjcOrNotObjc.performOperation!1 :
(ToObjcOrNotObjc) -> () -> Int,
$@convention(method) (@guaranteed ToObjcOrNotObjc) -> Int
%6 = apply %5(%4) : $@convention(method) (@guaranteed ToObjcOrNotObjc) -> Int
// call to performOcOperation()
%9 = class_method %8 : $ToObjcOrNotObjc, #ToObjcOrNotObjc.performOcOperation!1 :
(ToObjcOrNotObjc) -> () -> Int,
$@convention(method) (@guaranteed ToObjcOrNotObjc) -> Int
%10 = apply %9(%8) : $@convention(method) (@guaranteed ToObjcOrNotObjc) -> Int
There’s a similar pattern here: the first line gets a reference to the functions (%5
and %9
), then the following line uses apply
to make the call.
Notice the symbols like #ToObjcOrNotObjc.performOperation!1
match up with what you saw in the vtable earlier. Also, the calling convention is standard Swift $@convention(method)
.
Dynamic Dispatch
Next, what does the call to performDynamicOperation
look like?
// call to performDynamicOperation()
%13 = class_method [volatile] %12 :
$ToObjcOrNotObjc, #ToObjcOrNotObjc.performDynamicOperation!1.foreign :
(ToObjcOrNotObjc) -> () -> Int,
$@convention(objc_method) (ToObjcOrNotObjc) -> Int
%14 = apply %13(%12) : $@convention(objc_method) (ToObjcOrNotObjc) -> Int
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
:
// @objc ToObjcOrNotObjc.performOcOperation()
sil hidden [thunk] @_ToObjcOrNot_performOcOperationSiyFTo :
$@convention(objc_method) (ToObjcOrNotObjc) -> Int {
bb0(%0 : $ToObjcOrNotObjc):
%1 = copy_value %0 : $ToObjcOrNotObjc
%2 = begin_borrow %1 : $ToObjcOrNotObjc
// function_ref ToObjcOrNotObjc.performOcOperation()
%3 = function_ref @_ToObjcOrNot_performOcOperationSiyF :
$@convention(method) (@guaranteed ToObjcOrNotObjc) -> Int
%4 = apply %3(%2) : $@convention(method) (@guaranteed ToObjcOrNotObjc) -> Int
end_borrow %2 from %1 : $ToObjcOrNotObjc, $ToObjcOrNotObjc
destroy_value %1 : $ToObjcOrNotObjc
return %4 : $Int
} // end sil function '_ToObjcOrNot_performOcOperationSiyFTo'
Some highlights:
- 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 usedynamic
.
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. ⛏
}
Postscript
You can have a look at the full Swift code listing and SIL output as a gist.
When emitting SIL, the compiler uses the filename as the module name. I set the module name to a random word (“orchard” in this case) so it’s easy to spot and ignore.
Here’s the command I used to generate SIL:
$ swiftc objc-dispatch-demo.swift -emit-silgen -module-name orchard