@dynamicCallable Part 2: Swift/ObjC Bridge
In December we demonstrated how to use the new Swift 5
Dynamic Callable
feature to
run Unix commands as Swift functions,
like shell.ls()
.
Today we implement our very own Swift / Objective-C bridge using the same!
Of course Swift already has Objective-C integrated on the Apple platforms,
directly supported by the compiler, as well as the associated
bridging runtime.
Yet using
Dynamic Callable
you can actually build something similar at the library level,
and we want to show you how that would look like.
Swift also runs on Linux, but it doesn’t come with the Objective-C runtime and bridging features. Using the approach shown here with either libFoundation or GNUstep you could also combine Swift and Objective-C on Linux.
This is what we want to end up with:
let ma = ObjC.NSMutableArray()
ma.addObject("Hello")
.addObject("World")
print("Array:", ma.description())
Again inspired by the 🐍: This is very similar how Python/Objective-C bridges like PyObjC or NGPython work (how Python is able to access Objective-C objects and message them).
For demonstration purposes only: This is just a demo showing what you can do with @dynamicCallable, nothing more! (we also cheat a few times and silently rely on builtin bridging.)
You can follow along, or you can go ahead and grab
SwiftObjCBridge
from GitHub.
We recommend to read Part 1 first:
Unix Tools as Swift Functions
to get the basics, though not strictly required.
Important: Remember that you need to have Swift 5 via Xcode 10.2.
1. Looking up Objective-C Classes
The first thing we want to do is expose Objective-C classes to Swift. Like this:
let anObjCArray = ObjC.NSArray
To accomplish that, we create two things:
- a Swift struct which represents the class on the Swift side
- a global trampoline struct, which uses Dynamic Member Lookup to lookup the class
@dynamicMemberLookup
public struct ObjCRuntime {
public struct Class {
let handle : AnyClass?
}
public subscript(dynamicMember key: String) -> Class {
return Class(handle: objc_lookUpClass(key))
}
}
public let ObjC = ObjCRuntime() // global
print(ObjC.NSUserDefaults)
// Class(handle: Optional(NSUserDefaults))
This
objc_lookUpClass
function (a regular C API from the Objective-C runtime library)
returns a pointer representing the Objective-C class.
Which we just wrap in our Class
Swift struct.
We then create a single global instance (ObjC
),
representing our Objective-C bridge.
Due to the magic of
Dynamic Member Lookup
we can now just type ObjC.Anything
and receive our struct wrapping
the class runtime handle.
Since the Darwin Swift compiler knows about ObjC, it represents the handle directly as
AnyClass
. On Linux, we would use the GNU ObjC runtime C structure, i.e. struct objc_class * akaClass
.
That wasn’t very exciting, but we can already grab handles to Objective-C
classes using something which looks like plain Swift: ObjC.NSWorkspace
.
2. Sending Messages to Objective-C
In Objective-C, to invoke a method, you send a message
to an Objective-C object.
A message is a combination of a so called selector (like addObject:
) and an
optional list of arguments.
The neat thing is that all Objective-C classes are also
Objective-C (factory) objects!
For example to allocate a new NSMutableArray
instance, you would do this:
id array = [NSMutableArray alloc];
and we want to do the same using our bridge:
let array = ObjC.NSMutableArray.alloc()
This is a little more work. What we need to do is:
- we need a Swift struct representing an Objective-C object on the Swift side
- again use
Dynamic Member Lookup
to return an object representing the
alloc
message send: theNSMutableArray.alloc
part - use
Dynamic Callable
to invoke the message on the object:
the
()
part.
From an Objective-C perspective this is a little weird, because the messaging operation usually does both in a single step: method lookup and method invocation (in fact in Objective-C there doesn’t even have to be a method backing the selector, the object can dynamically decide how to react to messages, e.g. invoke a shell command instead 🤓).
As the name@dynamicCallable
suggests, Swift follows the Python Callables model which distinguishes between lookup and call.
Let us add the struct representing an arbitrary Objective-C object (including classes!), and one which represents the selector invocation:
@dynamicMemberLookup
public struct ObjCRuntime {
public struct Callable { // `object.doIt`
let instance : Object
let baseName : String
}
public struct Object {
let handle : AnyObject?
}
@dynamicMemberLookup
public struct Class {
let handle : AnyClass?
public subscript(dynamicMember key: String) -> Callable {
return Callable(instance: Object(handle: self.handle),
baseName: key)
}
}
public subscript(dynamicMember key: String) -> Class {
return Class(handle: objc_lookUpClass(key))
}
}
let call = ObjC.NSUserDefaults.alloc // <= No () yet!
print("Callable:", call)
// Callable: Callable(instance:
// X.ObjCRuntime.Object(handle: Optional(NSUserDefaults)),
// baseName: "alloc")
So what is happening here: To refresh @dynamicMember
knoff-hoff from
Unix Tools in Swift,
the compiler translates the
ObjC.NSUserDefaults.alloc
into:
ObjC[dynamicMember: "NSUserDefaults"] // yields our `Class`
[dynamicMember: "alloc"] // yields our `Callable`
Notice that when we do NSUserDefaults.alloc
, we turn the class handle into a
regular Object
handle (because a class is also a regular object):
Object(handle: self.handle)
.
Finally to actually invoke the alloc
method
(to allocate a NSUserDefaults instance),
we need to implement @dynamicCallable
on our Callable
struct:
@dynamicCallable // <===
public struct Callable { // `object.doIt`
let instance : Object
let baseName : String
@discardableResult
func dynamicallyCall(withKeywordArguments arguments: Args)
-> Object
{
guard let target = instance.handle else { return instance }
let stringSelector = baseName
let selector = sel_getUid(stringSelector)
guard let isa = object_getClass(target),
let m = class_getMethodImplementation(isa, selector) else {
return Object(handle: nil)
}
typealias M0 = @convention(c)
( AnyObject?, Selector ) -> AnyObject?
let typedMethod = unsafeBitCast(m, to: M0.self)
let result = typedMethod(target, selector)
return Object(handle: result)
}
}
let ud = ObjC.NSUserDefaults.alloc() // <= Now with () !
print("instance:", ud)
// instance: Object(handle:
// Optional(<NSUserDefaults: 0x103510930>))
Yay! We got an object allocated at address 0x103510930
!
To refresh what the compiler does when he sees ObjC.NSUserDefaults.alloc()
:
ObjC[dynamicMember: "NSUserDefaults"] // yields our `Class`
[dynamicMember: "alloc"] // yields our `Callable`
dynamicallyCall(withKeywordArguments: [])
Let’s step through the dynamicallyCall(withKeywordArguments:)
:
guard let target = instance.handle else { return instance }
This is to support nil messaging. A message sent to nil
just yields nil
,
it doesn’t crash or anything. The instance we return already is nil
.
If we wouldn’t do this, we would have to use Swift optional chaining, like:
ObjC.NSUserDefaults?.alloc?()
.
let stringSelector = baseName // 'alloc'
let selector = sel_getUid(stringSelector)
We turn the “alloc” string into a proper Objective-C runtime selector using
the
sel_getUid
C function.
guard let isa = object_getClass(target),
let m = class_getMethodImplementation(isa, selector) else {
return Object(handle: nil)
}
Getting the class (the isa
) of the Objective-C object
using
object_getClass
.
Remember: The object we are working is itself a class object!, so we are retrieving the class of the class aka the “meta class”.
Then the method is looked up in the class using
class_getMethodImplementation
,
which returns a pointer to the method implementation.
What is this pointer?
Objective-C methods are implemented as kinda regular C functions. The method
arguments and return types are reflected in the “C” generated by the compiler.
In addition to that, all methods receive two extra arguments:
self
and _cmd
. self
should be self explanatory, and _cmd
is the
message selector that was used to invoke the function (alloc
in our case).
In short, our +alloc
method looks kinda like this in plain C:
id NSObject_alloc(id self, SEL _cmd) {}
It takes no arguments on the Objective-C side, and returns an object pointer.
To invoke it from Swift, we need to cast the OpaquePointer (IMP
) we got
from
class_getMethodImplementation
to a Swift function:
typealias M0 = @convention(c)
( AnyObject?, Selector ) -> UnsafeRawPointer?
let typedMethod = unsafeBitCast(m, to: M0.self)
We can then just call the method as a Swift function:
let result = typedMethod(target, selector)
return Object(handle: result)
And we return the result back, wrapped in our Object
struct.
Phew. Quite some things to understand, but it works 🤓
The techniques are the same used for
method swizzling.
The attentive reader might wonder about ARC. Stay tuned!
Hurray. We can now send unary messages to class objects and thereby allocate new Objective-C instances:
let ud = ObjC.NSUserDefaults.alloc()
3. Sending Messages to Instances
Now that we have an allocated instance, we’d like to send messages to it!
That is trivial to add based on our Class
implementation,
just add the same
Dynamic Member Lookup
to the Object
struct:
@dynamicMemberLookup
public struct Object {
let handle : AnyObject?
public subscript(dynamicMember key: String) -> Callable {
return Callable(instance: self,
baseName: key)
}
}
Try it:
let ud = ObjC.NSUserDefaults.standardUserDefaults()
let domains = ud.volatileDomainNames()
print("domains:", domains)
// domains: Object(handle: Optional(<__NSArrayI 0x100f11010>(
// NSRegistrationDomain, NSArgumentDomain)))
Works!
4. Sending Messages with Arguments
All this let’s us invoke unary methods, that is, methods w/o any arguments. Next thing to fix. We want to do this:
let ma = ObjC.NSArray.alloc().`init`()
let ma2 = ma.arrayByAddingObject("Hello")
With our current bridge, you’ll get the typical
unrecognized selector sent to instance
exception. Why? Because we just send a message with no arguments and with the
arrayByAddingObject
selector to the mutable array.
But the selector we want to send is arrayByAddingObject:
, notice the colon
signaling the argument.
For this we need to go back to our dynamicallyCall
implementation. Right now
we map the selector like this:
let stringSelector = baseName
For object.doThis(with: "blah", and: "blub")
we always just
send doThis
(the baseName
) instead of the required doThis:with:and:
.
The other components of the selector need to be derived from the
arguments
argument of the dynamicallyCall
. Like so:
let stringSelector = arguments.reduce(baseName) {
$0 + $1.key + ":"
}
In addition we need to add additional C function signatures for methods with arguments. Let’s do it for the variant with one argument, here is the whole thing:
@discardableResult
func dynamicallyCall(withKeywordArguments arguments: Args)
-> Object
{
guard let target = instance.handle else { return instance }
let stringSelector = arguments.reduce(baseName) {
$0 + $1.key + ":"
}
let selector = sel_getUid(stringSelector)
guard let isa = object_getClass(target),
let m = class_getMethodImplementation(isa, selector) else {
return Object(handle: nil)
}
typealias M0 = @convention(c)
( AnyObject?, Selector ) -> AnyObject?
typealias M1 = @convention(c)
( AnyObject?, Selector, AnyObject? ) -> AnyObject?
switch arguments.count {
case 0:
let typedMethod = unsafeBitCast(m, to: M0.self)
let result = typedMethod(target, selector)
return Object(handle: result)
case 1:
let typedMethod = unsafeBitCast(m, to: M1.self)
let result = typedMethod(target, selector,
arguments[0].value as AnyObject)
return Object(handle: result)
default:
fatalError("can't do that count yet!")
}
}
All the argument mapping and ptr casting is necessary because we need to dynamically produce a proper C ABI function call. To be able to call any combination of C base types (instead of just sending messages whose arguments are itself objects), you’d usually use a FFI library, like libffi.
Does it run? Yes it does!
let ma = ObjC.NSArray.alloc().`init`()
let ma2 = ma.arrayByAddingObject("Hello")
print("★:", ma2)
// ★: Object(handle: Optional(<__NSSingleObjectArrayI 0x103600380>(
// Hello)))
BTW: we have to backtick init
, so that we can use it as a regular Swift
identifier (otherwise Swift considers it a Swift initializer).
5. Fixing Void Results
You may have wondered that arrayByAddingObject:
instead of addObject:
was used to demo the thing. That had a reason 😜
Our signatures deal with methods returning object values, but addObject:
is a Void
method. If we invoke it, we crash, because ARC will attempt to
release the non-existing result.
First we need to figure out the return type using the method_getReturnType function:
var buf = [ Int8 ](repeating: 0, count: 46)
method_getReturnType(i, &buf, buf.count)
let returnType = String(cString: &buf)
The returnType will be "@"
for an object, "v"
for Void, "i"
for integer,
etc.
(checkout NSMethodSignature, which is not available in Swift).
For -(void)addObject:(id)
we would get a "v"
and we know that we need to
hide the result from ARC.
To support that, the “result” handling needs to be adjusted a little.
@discardableResult
func dynamicallyCall(withKeywordArguments arguments: Args)
-> Object
{
...
guard let isa = object_getClass(target),
let i = class_getInstanceMethod(isa, selector) else {
return Object(handle: nil)
}
let m = method_getImplementation(i)
var buf = [ Int8 ](repeating: 0, count: 46)
method_getReturnType(i, &buf, buf.count)
let returnType = String(cString: &buf)
typealias M0 = @convention(c)
( AnyObject?, Selector ) -> UnsafeRawPointer?
typealias M1 = @convention(c)
( AnyObject?, Selector, AnyObject? ) -> UnsafeRawPointer?
let result : UnsafeRawPointer?
switch arguments.count {
case 0:
let typedMethod = unsafeBitCast(m, to: M0.self)
result = typedMethod(target, selector)
case 1:
let typedMethod = unsafeBitCast(m, to: M1.self)
result = typedMethod(target, selector,
arguments[0].value as AnyObject)
default:
fatalError("can't do that count yet!")
}
if returnType == "@" {
return Object(handle:
unsafeBitCast(result, to: AnyObject?.self))
}
return self.instance
}
Runs:
let mm = ObjC.NSMutableArray.alloc().`init`()
mm.addObject("Hello")
print("★★:", mm)
// ★★: Object(handle: Optional(<__NSArrayM 0x103b09740>(
// Hello)))
Notice the fallback “return self.instance
”? That allows us to cascade void
messages,
which is not possible in Objective-C
(but in Swifter):
ma.addObject("1").addObject("2")
6. Fixing ARC Retain Counts
Very nice already. But ARC is actually still not quite right. As you can see we are just casting the raw pointer to an ARC Swift object:
if returnType == "@" {
return Object(handle:
unsafeBitCast(result, to: AnyObject?.self))
}
The only reason that this doesn’t crash right away is because all methods
we called so far either return a retained object (+alloc
, -init
),
or an autoreleased
object (+arrayByAddingObject
), w/o an autorelease pool.
ARC is a relatively new technology for (Apple) Objective-C. With ARC
the compiler knows the reference counts of the objects,
and Automatically increases/decreases the Reference Counts.
However, in pre-ARC Objective-C, it was the developers choice whether a method
returned retained objects (one which needs to be released) or
autoreleased objects (one which doesn’t need to be released manually).
To workaround this, ARC ties a
convention
to the selector. Selectors beginning
with:
- new, alloc, copy, mutableCopy, init
return a retained object. All other selectors return autoreleased objects. Since we dynamically call our method, we need to check this convention:
private func shouldReleaseResult(of selector: String) -> Bool {
return selector.starts(with: "alloc")
|| selector.starts(with: "init")
|| selector.starts(with: "new")
|| selector.starts(with: "copy")
}
Once we have that, we can produce a proper AnyObject
reference instead of
bitcasting the raw pointer:
if returnType == "@" {
guard let result = result else {
return Object(handle: nil)
}
let p = Unmanaged<AnyObject>.fromOpaque(result)
return shouldReleaseResult(of: stringSelector)
? Object(handle: p.takeRetainedValue())
: Object(handle: p.takeUnretainedValue())
}
First we check for nil
. If it is not, we create an
Unmanaged
reference from the pointer. And subsequently grab the
object reference with the proper ARC retain count.
7. Making Classes Callable
A final convenience. To create objects we do this Objective-C flow:
let ma = ObjC.NSMutableArray.alloc().`init`()
Not nice, we want:
let ma = ObjC.NSMutableArray()
Remember that ObjC.NSMutableArray
returns us our Class
struct.
So all we need to do is make that
@dynamicCallable!
(in addition to @dynamicMemberLookup
, i.e. they work together just fine):
@dynamicCallable
@dynamicMemberLookup
public struct Class {
let handle : AnyClass?
public subscript(dynamicMember key: String) -> Callable {
return Callable(instance: Object(handle: self.handle),
baseName: key)
}
@discardableResult
func dynamicallyCall(withKeywordArguments args: Args)
-> Object
{
return self
.alloc()
.`init`
.dynamicallyCall(withKeywordArguments: args)
}
}
Notice how we use @dynamicMemberLookup
s and the @dynamicCallable
within our own implementation (to find & call alloc and to find init).
Also note that we don’t call init()
but pass along the arguments
we got!
Makes this thing happen: NSMutableArray(WithContentsOfURL:)
(calling
initWithContentsOfURL:
).
let ms = ObjC.NSMutableArray()
ms.addObject("Happy")
ms.addObject("Birthday")
print("★★★:", ms)
// ★★★: Object(handle: Optional(<__NSArrayM 0x1032022c0>(
// Happy,Birthday)))
Summary
You can find the “finished” bridge over here: SwiftObjCBridge.swift. It even comes with tests! ⛑
Again: For demonstration purposes only: This is just a demo showing what you can do with @dynamicCallable, nothing more!
The code didn’t have any cows, so let’s at least have this one: 🐄