Home

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.