Casting NSDocument to protocol declared in Swift?

I'm working on a MacDown plug-in, based off some sample code I found. Theirs is written in Objective-C, but they don’t provide a header. Instead, you access application state by declaring your a protocol and casting from a singleton to that:

@protocol MacDownMarkdownSource <NSObject>

@property (readonly) NSString *markdown;

@end

...

{
    NSDocumentController *dc = [NSDocumentController sharedDocumentController];
    id<MacDownMarkdownSource> markdownSource = (id)document;
}

I tried to do something similar:

@objc
protocol
MacDownMarkdownSource : NSObjectProtocol
{
	@objc			var			markdown		:	String { get }
}

...

{
	let dc = NSDocumentController.shared
	let doc = dc.currentDocument as! MacDownMarkdownSource
}

But it SIGABRTs with this message logged to the console:

Could not cast value of type 'NSKVONotifying_MPDocument' (0x600000df8c60) to 'MacDownInsertDatePlugin.MacDownMarkdownSource' (0x111ea90c8).

If I remove the @objc attributes, it dies with Swift runtime failure: type cast failed (although that shows up in Xcode's source editor, not as a message logged to the console.

Then I tried putting the Objective-C protocol declaration in the bridging header, which compiles, but has nearly the same SIGABRT error:

Could not cast value of type 'NSKVONotifying_MPDocument' (0x600000ab6fd0) to 'MacDownMarkdownSource' (0x107ae00d0).

In the MacDown source, MPDocument inherits from NSDocument and has no protocol conformances.

Is there a way to coax Swift into letting me do this?

This works in Objective-C because it's kind of a cheat; this line:

    id<MacDownMarkdownSource> markdownSource = (id)document;

doesn't actually do a runtime cast; it just tells the compiler to treat the pointer to the object as if it conforms to that protocol so that when you call its properties/methods it can do better type-checking of the arguments/return values. The name of the protocol doesn't actually matter, as long as you never try to do anything with it that involves the runtime (like conformsToProtocol:).

In Swift, as?/as! always involves a runtime check, so the same protocol trick won't work; the type you cast to in Swift needs to be the same Objective-C type/protocol that the underlying class is.

Does it work if you create a bridging header that declares a stub @interface for MPDocument with the markdown property, and then cast to MPDocument in your Swift code? If this is being linked as a Mach-O bundle with the bundle loader as the MacDown binary, then I think you should be able to use the interface that way without providing an implementation and you won't get any linker errors about undefined symbols.

1 Like

No, unfortunately it's a plug-in, loaded dynamically. If I make the stub interface, it fails to link (can't find the MPDocument implementation)

If you created the project/target as a "Bundle" in Xcode then it should be already linking as a Mach-O bundle (MH_BUNDLE). (There's no "plug-in" binary type; MacDown uses NSBundle APIs to dynamically load the plug-in so whatever you're building must be either a bundle or a dylib, in Mach-O terms.) The "bundle loader" setting tells the linker to also look in another executable (the thing that will load the bundle) for any symbols that are undefined, so if that isn't set, then the linker won't be able to find them.

It looks like the sample plug-in you linked to uses a product type of com.apple.product-type.bundle, so it's creating a MH_BUNDLE-type binary. It doesn't specify a bundle loader anywhere, but it doesn't have to, because it's using the protocol trick that avoids needing to reference the actual Objective-C class symbol.

If you go to the Linking section of your target settings in Xcode, you can double-check your setup; if you've created your project the same way as the sample code, I think you'll see "Mach-O Type" as "Bundle" and "Bundle Loader" as blank.

So, you've got a couple options, either of which I think will work:

  1. Set the "Bundle Loader" setting to the path to the MacDown binary, so the linker looks there for the MPDocument class symbol.

  2. You could also try adding __attribute__((objc_runtime_visible)) before your stub @interface, which tells the compiler that the class is visible to the runtime but not to the linker. Swift knows how to handle classes with this attribute so that if you do a dynamic cast, it does a runtime lookup of the class instead of trying to use a symbol linked into the binary.

__attribute__((objc_runtime_visible)) brings some restrictions with it—you can't subclass it and you can't add categories to it. I'm not sure if the latter restriction also prohibits non-Objective-C-exported Swift extensions; I've never tried it. You may not need any of those, but it's good to know the limitations.

1 Like

@allevato That was an incredibly helpful and informative reply, thank you! I tried the attribute, and it seems to have worked well for my purposes (so far), although I guess there's no pure-Swift way to do this, huh? That is, a way to do it without any Obj-C?

In the MacDown source, the MPDocument doesn't conform to a protocol. If that protocol existed in the Obj-C, could I make one (as I tried) in Swift (and linked against the app)?

I think that would be ok, specifically for protocols, due to the way their Obj-C metadata is handled during linking. (But maybe someone else can chime in if there's something about Swift's additional metadata that would make this unsafe.)

If you were in a situation where it was possible to do this, be careful about a subtlety in the way module names are handled for @objc types/protocols:

  • @objc protocol Foo has the Objective-C name "ModuleName.Foo" (with some name mangling)
  • @objc(Foo) protocol Foo has the Objective-C name "Foo"

So if you needed to match an Objective-C protocol declared in another binary, you need to specify the name explicitly (even if it's the same as the Swift name) to remove the module name from it. Otherwise, the cast will fail because the names don't match in the metadata.

2 Likes