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.