Runtime in Swift: Exposing Unavailable Classes

Tue, 17 Sep 2024 12:27:00 +0200

Some lessons learned while building a Swift API for the ObjC runtime functions, part 3.

This is the last post in a series about building a Swift wrapper of the Objective-C runtime. It’s about how I created Swift APIs for two runtime-related classes which are unavailable in Swift.

1. The lifetime of strings passed into C functions

2. Getting my Swift wrapper to be as fast as calling into C directly

3. Exposing some runtime-related classes that are marked as unavailable in Swift (this post)

When I had almost finished wrapping all the runtime functions in a Swift API, I realised there were two classes I also needed to support: NSInvocation and NSMethodSignature. While these are not strictly part of the runtime C functions, they are closely related; Apple even categorises them under “Object Runtime” in their documentation.

What do these classes do?

NSInvocation is an object that captures all the details of a method call. If you think about what any method call involves—a target object, a selector, parameter values and a return value—those are literally all properties on an NSInvocation. It’s like a snapshot of any method call, except all those properties can be modified. The parameter values can be changed before you “invoke” the method call and the return value can be changed afterwards. The method can be called on a different target object or even invoked multiple times. It is mainly used with the forwardInvocation: method on NSObject, but you can also create one from scratch and invoke it manually.

NSMethodSignature is a closely-related class which encapsulates all the parameter types and return type of a method. It is created from a type encoding string and essentially wraps all the method-related runtime functions like method_getArgumentType and method_getNumberOfArguments.

Both of these classes are very useful for working with the runtime, which is why I wanted to add them to my Swift runtime wrapper.

Creating the Swift API

I started writing a Swift extension to NSInvocation thinking “this shouldn’t take long”.

⛔️ ‘NSInvocation’ is unavailable in Swift: NSInvocation and related APIs not available

Oh. Damn.

The exact classes I needed turned out to be the only ones that are just flat-out not available in Swift with no suggested replacement. I don’t see any particular reason they’re blocked apart from “you probably don’t need to deal with this stuff in Swift”.

Well that’s not going to stop me. I guess I’m going to have to call these runtime classes dynamically using… the runtime.

Sending messages

Here’s how [NSInvocation invocationWithMethodSignature:] can be called without referring to the class or initialiser directly:

func createNSInvocation(with methodSignature: Any) -> Any? {
    let cls: AnyObject = NSClassFromString("NSInvocation")!
    let sel = NSSelectorFromString("invocationWithMethodSignature:")
    return cls.perform(sel, with: methodSignature).takeUnretainedValue()
}

This uses the “perform selector” technique to send a message to the class object. Note that while NSClassFromString returns AnyClass, there are no functions on the type itself so it has to be cast to AnyObject before calling perform(). The method signature parameter and returned object also both need to be of Any since the real types can’t be used in Swift. It’s ugly but it works. So now let’s create the needed method signature with [NSMethodSignature signatureWithObjCTypes:] which takes a type encoding C string:

func createNSMethodSignature(with types: String) -> Any? {
    let cls: AnyObject = NSClassFromString("NSMethodSignature")!
    let sel = NSSelectorFromString("signatureWithObjCTypes:")
    return cls.perform(sel, with: types.utf8CString).takeUnretainedValue()
}

Except this time the perform() call crashes with a curious message:

Thread 1: “NSGetSizeAndAlignment(): unsupported type encoding spec ‘±’ at ‘±Ó.]¯ˇ\U0000001d\U00000001HB/]¯\U0000007f’ in ‘±Ó.]¯ˇ\U0000001d\U00000001HB/]¯\U0000007f’”

This is clearly a problem with the type encoding parameter. Maybe types.utf8CString is not correctly being mapped to the const char * that the method expects?

Actually the real issue is that perform() only accepts object parameters, so it can’t even be used with methods that take scalars or pointers. Ironically, the documentation says that if you need to pass anything else, use NSInvocation:

This method is the same as perform(_:) except that you can supply an argument for aSelector. aSelector should identify a method that takes a single argument of type id. For methods with other argument types and return values, use NSInvocation.

It appears that it’s impossible to even create an instance of NSMethodSignature using this technique then. Time to go deeper.

Calling method implementations

Instead of sending a message to the NSMethodSignature class, it’s possible to ask the runtime for the actual function pointer to that method and call that manually. After getting the class and selector as before, the function pointer (IMP) is determined like so:

func createNSMethodSignature(with types: String) -> Any? {
    let cls: AnyClass = NSClassFromString("NSMethodSignature")!
    let sel = NSSelectorFromString("signatureWithObjCTypes:")
    let method = class_getClassMethod(cls, sel)!
    let imp = method_getImplementation(method)

But then how is this IMP called in Swift? It needs to be cast from an untyped pointer to match the expected signature of the signatureWithObjCTypes: method. As with all Objective-C methods, this is a function which takes the object that the message is being sent to (the NSMethodSignature class), the selector and any other parameters. In Swift this looks like:

    typealias FuncSig =
        @convention(c) (AnyClass, Selector, UnsafePointer<CChar>) -> AnyObject?

The @convention(c) indicates that this is a C-style function signature instead of a Swift function.

The IMP can now be cast to this function type and called like any other closure:

    let fn = unsafeBitCast(imp, to: FuncSig.self)
    let obj = types.withCString {
        fn(cls, sel, $0)
    }
    return obj
}

So finally this returns a valid NSMethodSignature, which can be used to create an NSInvocation.

Exposing the full API

Getting an NSInvocation as an Any is not particularly useful, since I want to use all the methods of the original class. I decided to wrap the original object in a new Swift struct:

struct Invocation {
    let nsInvocation: AnyObject
}

Now how to expose all the original methods and properties on the NSInvocation? Calling perform() will not work unless all the arguments and return type are objects, but the method IMP technique will work for any method. For example here’s argumentsRetained which is a simple read-only boolean property:

extension Invocation {
    var argumentsRetained: Bool {
        let cls: AnyClass = NSClassFromString("NSInvocation")!
        let sel = NSSelectorFromString("argumentsRetained")
        let imp = class_getMethodImplementation(cls, sel)!

        typealias Sig = @convention(c) (AnyObject, Selector) -> ObjCBool
        let fn = unsafeBitCast(imp, to: Sig.self)
        let result = fn(nsInvocation, sel)
        return result.boolValue
    }
}

Now I still need to do that for every other method? What a pain!

There must be a way to tell Swift “these methods exist on this untyped object, trust me, let me call them!”. That sounds a lot like what a protocol is for.

Calling via a protocol

If I take the public header of NSMethodSignature, generate a Swift API from it and wrap that in a protocol, this is what I get:

@objc protocol MethodSignatureProto: NSObjectProtocol {
    static func signature(withObjCTypes types: UnsafePointer<CChar>!) -> MethodSignatureProto?
    var numberOfArguments: UInt { get }
    func getArgumentType(at idx: UInt) -> UnsafePointer<CChar>!
    var frameLength: UInt { get }
    var isOneway: Bool { get }
    var methodReturnType: UnsafePointer<CChar>! { get }
    var methodReturnLength: UInt { get }
}

I’m inheriting from the NSObjectProtocol so that I also get basic object functionality like isEqual:.

So now all that’s needed is to cast the Any returned from createNSMethodSignature() to MethodSignatureProto and I can start calling methods on it. Swift will now even take care of bridging those UnsafePointer<CChar> parameters to String for me.

let methodSig = createNSMethodSignature(with: "v@:") as! MethodSignatureProto

Except oops, that crashes:

Could not cast value of type ‘NSMethodSignature’ to ‘MethodSignatureProto’.

How can I tell Swift that NSMethodSignature does conform to MethodSignatureProto? This was so much easier in Objective-C!

For someone who has spent a lot of time working with the runtime, it took longer than I’d like to admit to come up with the solution:

let cls: AnyClass = NSClassFromString("NSMethodSignature")!
class_addProtocol(cls, MethodSignatureProto.self)

I just needed to tell the runtime that NSMethodSignature conforms to MethodSignatureProto. Because of course the as operator is using that to test for protocol conformance. Now the cast works and I can call any of the underlying methods directly:

let methodSig = createNSMethodSignature(with: "v@:") as! MethodSignatureProto

print(methodSig.frameLength)    // 224
print(methodSig.isOneway)       // false
print(String(cString: methodSig.methodReturnType))  // "v"
print(methodSig.getArgumentType(at: 0))     // crash!

The last one crashes with:

-[NSMethodSignature getArgumentTypeAt:]: unrecognized selector

I thought it was going to be some new drama but it turned out to be simple. The method name in Objective-C is actually getArgumentTypeAtIndex: but that doesn’t round-trip through the generated Swift name properly. It’s easily fixed by overriding the ObjC name in the protocol:

@objc(getArgumentTypeAtIndex:)
func getArgumentType(at idx: UInt) -> UnsafePointer<CChar>!

This protocol technique can also be used to call static methods, so createNSMethodSignature() can be replaced by:

let cls: AnyClass = NSClassFromString("NSMethodSignature")!
let typedCls = cls as! MethodSignatureProto.Type
let methodSig = typedCls.signature(withObjCTypes: "v@:")

Using protocols like this made it much simpler to expose the unavailable NSInvocation and NSMethodSignature classes to Swift.

Calling directly on AnyObject

Something I’d forgotten until writing this post is that Swift actually allows any Objective-C method to be called directly on AnyObject!

let obj: AnyObject = createNSMethodSignature(with: "v@:")
print(obj.frameLength)    // 224
print(obj.isOneway)       // false
print(String(cString: obj.methodReturnType))    // "v"
print(obj.getArgumentType(at: 0))               // "@"

There’s no protocol involved here, Swift just exposes every ObjC selector it knows about on AnyObject. Even if those selectors are defined on a class that’s unavailable in Swift apparently.

It works surprisingly well, except in a couple of cases like NSInvocation.target where Swift complains about “ambiguous use of ‘target’”. That means multiple ObjC classes have a property called “target” but with differing types, so it doesn’t know which one to use. It also doesn’t seem possible to call a class method using this trick.

There are a few different options for exposing an unavailable class to Swift and luckily the best option (using protocols) turned out to not involve a ton of code. It’s hardly something that comes up often, but it’s nice that yet again the runtime lets us monkey around with the wiring inside our apps.

Useful links

Previous post: Getting my Swift wrapper to be as fast as calling into C directly

First post: The lifetime of strings passed into C functions

Apple’s docs for NSInvocation, NSMethodSignature, forwardInvocation, the @convention attribute and AnyObject

Type Encodings Explained post