Be careful with Obj-C bridging in Swift

Be careful with Obj-C bridging in Swift

Bridging to/from Objective-C is an important part of Swift development due to the Obj-C history of Apple's platforms. Unfortunately, there are some hidden caveats that could lead to bizarre situations that would be impossible in a pure Swift environment. When dealing with Objective-C types, it's useful to check if they don't have a history of being too different from their Swift counterparts.

The reason for the carefulness is because bridging can be completely hidden from you. As you might know, Swift developers can use the as upcast operator to convert a type to one of the superclasses or protocols that it inherits from:

let myViewController = MyViewController()
let viewController = myViewController as UIViewController

There is no change in functionality between myViewController and viewController because all the operator does is limit what you can access from that type. Deep down, they are still the same object.

However, as is also the Obj-C bridging operator:

let string = "MyString"
let nsstring = string as NSString

While visually the same, this case is completely different from the view controllers one! String does not inherit or uses NSString in any way -- they are different objects with different implementations. The way this works is that as in this case is a syntax sugar for the following:

let string = "MyString"
let nsstring: NSString = string._bridgeToObjectiveC()

This method comes from the _ObjectiveCBridgeable protocol, which allows objects the automatically convert a Swift type to an Objective-C equivalent when needed, as well as giving the free as cast behavior we've seen:

extension Int8 : _ObjectiveCBridgeable {
    @_semantics("convertToObjectiveC")
    public func _bridgeToObjectiveC() -> NSNumber {
        return NSNumber(value: self)
    }
}

What can go wrong with this? Unfortunately, everything. Consider the following example:

let string = "MyString"
let range = string.startIndex..<string.endIndex

let roundTrip = (string as NSString) as String
roundTrip[range]

What do you think will happen in the last line?

This code works fine today, but it was actually a source of crashes around Swift 4! From a Swift point of view there's nothing wrong with this code, because converting String to NSString and back to String again technically does nothing. But from a bridging point of view, the final String is a different object from the first one! The act of "converting" String to NSString is actually the creation of a brand new NSString that has its own storage, which will repeat when it gets "converted" back to String. This makes the range values incompatible with the final string, resulting in a crash.

Let's take a look at a different example. Protocols can be exposed to Obj-C by using @objc, which from the Swift side allows metatypes to be used as Obj-C's Protocol pointers.

@objc(OBJCProto) protocol SwiftProto {}

let swiftProto: SwiftProto.Type = SwiftProto.self
let objcProto: Protocol = SwiftProto.self as Protocol
// or, from the Obj-C side, NSProtocolFromString("OBJCProto")

If we compare two swift metatypes, they will trivially be equal:

ObjectIdentifier(SwiftProto.self) == ObjectIdentifier(SwiftProto.self)
// true

Likewise, if we upcast a metatype to Any.Type, the condition will still be true as they are still the same object:

ObjectIdentifier(SwiftProto.self as Any.Type) == ObjectIdentifier(SwiftProto.self)
// true

So if, say, I upcast it to something else like AnyObject, this will still be true, right?

ObjectIdentifier(SwiftProto.self as AnyObject) == ObjectIdentifier(SwiftProto.self)
// false

No, because we're not upcasting anymore! "Casting" to AnyObject is also a bridge syntax sugar that converts the metatype to Protocol, and because they are not the same object, the condition stops being true. The same thing happens if we treat it as Protocol directly:

ObjectIdentifier(SwiftProto.self) == ObjectIdentifier(SwiftProto.self)
// true
ObjectIdentifier(SwiftProto.self as Protocol) == ObjectIdentifier(SwiftProto.self)
// false

Cases like this can be extremely confusing if your Swift method cannot predict where its arguments are coming from, because as we can see above, the very same object can completely change the result of an operation depending on if it was bridged or not. If it wasn't enough, things get even worse when you deal with the fact that the very same method can have different implementations across languages:

String(reflecting: Proto.self) // __C.OBJCProto
String(reflecting: Proto.self as Any.Type) // __C.OBJCProto
String(reflecting: Proto.self as AnyObject) // Protocol 0x...
String(reflecting: Proto.self as Protocol) // Protocol 0x...

Even though from a Swift point of view it looks like these are all the same object, the results differ when bridging kicks in because Protocol descriptions are implemented differently than Swift's metatypes'. If you're trying to convert types to strings, you need to make sure you're always using their bridged version:

func identifier(forProtocol proto: Any) -> String {
    // We NEED to use this as an AnyObject to force Swift to convert metatypes
    // to their Objective-C counterparts. If we don't do this, they are treated as
    // different objects and we get different results.
    let object = proto as AnyObject
    //
    if let objcProtocol = object as? Protocol {
        return NSStringFromProtocol(objcProtocol)
    } else if let swiftMetatype = object as? Any.Type {
        return String(reflecting: swiftMetatype)
    } else {
        crash("Type identifiers must be metatypes -- got \(proto) of type \(type(of: proto))")
    }
}

If you don't convert the type to AnyObject, the very same protocol may give you two different results depending on how your method was called (for example, an argument provided in Swift versus in Obj-C). This is the most common source of bridging issues, as a similar case existed with NSString a few versions ago where a method had different implementations when compared to String, which caused issues in cases where a Swift string was automatically converted to an NSString.

Conclusion

I personally think that using as as a syntax sugar for bridging was not the best idea. From the developer's point of view it's clear that string._bridgeToObjectiveC() may cause the object to change, while as indicates the opposite. _ObjectiveCBridgeable is a public protocol, but it's not supported for general use. In general, be aware of custom types implementing it, and pay extra attention when you're upcasting to make sure you're not bridging types when you didn't mean to.