Interoperability
Objective-C and C from Swift, and what the bridge costs
Swift did not arrive on an empty platform. Every Apple framework older than 2014 is Objective-C or C underneath, and a great deal of what looks like a Swift API is a translation of one.
Most of the time the translation is invisible and free. The cases where it is neither are worth knowing, because they are where surprising performance and surprising crashes come from.
What crosses the Objective-C boundary
Only some of Swift is expressible in Objective-C. A type is visible from Objective-C when it is a
class inheriting from NSObject and marked @objc, and only members whose signatures are
expressible cross with it:
@objc final class Analytics: NSObject {
@objc func track(_ event: String) { … } // fine
func record(_ result: Result<Int, Error>) { … } // Swift-only: no Objective-C equivalent
}
Structs, enums with associated values, generics, tuples, protocols with associated types, and
existentials with where clauses have no Objective-C representation. They do not produce an error
unless you mark them @objc — they are simply not visible from that side.
@objcMembers on a class exposes everything it can, which is convenient and usually too broad. It
opts the whole class into message dispatch for anything Objective-C can see, with the costs described
in the dispatch chapter.
The bridged types
Some types convert automatically at the boundary:
| Swift | Objective-C |
|---|---|
String |
NSString |
Array |
NSArray |
Dictionary |
NSDictionary |
Set |
NSSet |
Int, Double |
NSNumber |
Data |
NSData |
Error |
NSError |
The conversion is not always free, and this is the part that surprises people. String to NSString
is usually cheap because the storage can often be shared. Array to NSArray may require copying
every element — and if the elements are Swift value types, each one has to be boxed individually.
let points: [CGPoint] = …
someObjectiveCAPI.process(points) // bridging cost proportional to count
In a loop over a large array, that cost is real. The fix is not to avoid the API but to cross the boundary once with the whole collection rather than repeatedly with pieces of it.
Warning
as? NSString-style casts between bridged types look like free type conversions and are not. A
cast in a hot loop is a bridging operation each time round. Hoist it out.
Nullability, and what an unannotated header does
Objective-C references may be null; Swift’s are not. The bridge relies on annotations in the header:
- (nullable NSString *)nameForID:(NSInteger)identifier; // imports as String?
- (nonnull NSString *)displayName; // imports as String
- (NSString *)legacyName; // imports as String!
The third is the dangerous one. An unannotated method imports as an implicitly unwrapped optional — Swift will not force you to check it, and it will crash at the point of use if it is nil.
When wrapping an old Objective-C library, the highest-value change is adding
NS_ASSUME_NONNULL_BEGIN / NS_ASSUME_NONNULL_END to the headers and marking the genuine
nullable cases. That converts a class of runtime crashes into compile-time optionals.
Errors across the boundary
Objective-C reports errors with an NSError ** out-parameter and a BOOL return. Swift imports that
pattern as throws:
- (BOOL)saveToURL:(NSURL *)url error:(NSError **)error;
try document.save(to: url)
The translation is mechanical and works well. Going the other way, a Swift Error becomes an
NSError with domain, code and userInfo — so a Swift enum error crossing into Objective-C
loses its associated values unless the type conforms to CustomNSError and provides them in
errorUserInfo.
C, and the pointer types
C APIs import with pointers mapped to Swift’s Unsafe family:
| C | Swift |
|---|---|
const void * |
UnsafeRawPointer |
void * |
UnsafeMutableRawPointer |
const int * |
UnsafePointer<Int32> |
int * |
UnsafeMutablePointer<Int32> |
char * |
UnsafeMutablePointer<CChar> |
The critical rule about all of these: a pointer obtained inside a with… closure is valid only
inside that closure.
// Wrong — the pointer dangles the moment the closure returns
var escaped: UnsafePointer<UInt8>?
data.withUnsafeBytes { escaped = $0.baseAddress?.assumingMemoryBound(to: UInt8.self) }
// Right — do the work inside
data.withUnsafeBytes { buffer in
cLibraryFunction(buffer.baseAddress, buffer.count)
}
Swift may allocate a temporary buffer for the duration of the call and free it afterwards, so the escaped pointer is not merely stale — it points at memory that has been returned. The resulting bug is intermittent and looks like data corruption, which is the worst combination.
For C strings, withCString handles the encoding and the lifetime together:
name.withCString { pointer in
c_register_name(pointer)
}
When to write the bridge yourself
Calling a C or Objective-C API directly from application code spreads unsafety through the codebase. The pattern that keeps it contained is a thin Swift wrapper at the boundary:
struct ImageDecoder {
func decode(_ data: Data) throws -> Image {
try data.withUnsafeBytes { buffer in
var handle: OpaquePointer?
guard c_decode(buffer.baseAddress, buffer.count, &handle) == 0 else {
throw DecodeError.malformed
}
defer { c_free(handle) }
return Image(handle: handle)
}
}
}
One type owns the unsafe calls, the lifetimes and the error translation, and everything above it is
ordinary Swift with a throws signature. That boundary is where the defer for cleanup belongs, and
it is the only place in the codebase that needs reviewing when the C library changes.