The Objective-C runtime (libobjc) is the library that keeps classes, methods and selectors as data while the app runs; [obj doThing] compiles to objc_msgSend(obj, @selector(doThing)), which finds the implementation only at the moment of the call.
**Selector (SEL)**: the interned name of a method, such as setTitle:. Every class with a method of that name uses the same selector.
**IMP**: a plain C function pointer (id self, SEL _cmd, ...). A method is a selector paired with an IMP in the class's method list.
Late binding: the receiver's class is consulted at call time, so a method can be added, replaced or forwarded while the app runs, and you can ask respondsToSelector: first.
Swift: calls its own methods directly; objc_msgSend is used only for Objective-C classes and @objc dynamic members.
iOSSenior
Objective-C Runtime & Interop
Message dispatch, swizzling, bridging, KVO internals, when Objective-C is still needed
Questions closed
0 / 18
- 0Review
- 7Asked often
Questions
Nothing happens and nothing crashes: objc_msgSend checks for nil first and returns zero, so you get nil, 0, NO or 0.0. Swift makes the same case explicit with optionals: obj?.run() is skipped and gives an optional result; force-unwrapping a nil crashes.
Why it is handy: chains like [[user profile] name] need no checks, and [delegate didFinish] with no delegate set is harmless.
Why it hurts: a missing outlet or a delegate that was never set fails silently: no crash, no log, the feature just does nothing.
**nil as an argument is different**: [array addObject:nil] or @{ @"key": nil } throws NSInvalidArgumentException. Only a nil receiver is safe.
@objc publishes a member to the Objective-C runtime (a selector and an entry in the method list), @objcMembers does that for a whole class, and dynamic makes Swift itself call the member through objc_msgSend. Without dynamic, Swift callers bypass the runtime.
**@objc**: needed for #selector, #keyPath, target-action, @objc protocols and calls from Objective-C files. Swift code still calls the member directly or through the vtable, so a runtime change to that method is invisible to Swift callers.
Inference: since Swift 4 (SE-0160) @objc is inferred only for overrides of Objective-C methods, @objc protocol members, @IBAction, @IBOutlet and @NSManaged.
**@objcMembers**: @objc on every member that Objective-C can represent, extensions included. Handy for a class used from Objective-C; it costs binary size.
**dynamic**: write @objc dynamic: every call, from Swift too, becomes a message send that KVO's generated subclass or a swizzle can intercept. Every type in the signature must be representable in Objective-C. Plain dynamic without @objc is Swift's native dynamic replacement, which the Objective-C runtime does not see.
Rule of thumb: @objc when Objective-C must see the member, @objc dynamic when the runtime must be able to change it.
KVC (key-value coding) reads and writes properties by a string key at run time; KVO (key-value observing) notifies observers when such a property changes. From Swift you call observe(\.status) on an @objc dynamic property and keep the token; your own new models use @Observable instead.
KVC: value(forKey:), setValue(_:forKey:) and key paths such as "address.city" find the property by name at run time. A wrong key raises NSUnknownKeyException ("not key value coding-compliant for the key"), the classic crash after renaming an outlet a storyboard still connects.
KVO from Swift: the property must be @objc dynamic on an NSObject subclass. observe(_:options:changeHandler:) returns an NSKeyValueObservation, and observation lasts only while that token lives, so store it in a property.
Threading: the handler runs synchronously on the thread that changed the value, not on the main thread. Hop to the main actor before touching UI.
Where KVO is still the API: Apple's own objects, such as AVPlayer.timeControlStatus, WKWebView.estimatedProgress and Progress.fractionCompleted.
Replacements: @Observable for your own models, @Published in Combine code, and Observations { model.total } (iOS 26) for an AsyncSequence of changes.
A category is a named add-on (@interface UIView (Badge)) that attaches methods to any class when the binary loads, even one you have no source for; a class extension (@interface Foo ()) is an anonymous part of the class itself, compiled with its @implementation, and can add ivars.
Category: methods and property declarations, but no storage: a @property in a category gets no ivar and no synthesized accessors. You write them yourself, often with associated objects.
Class extension: lives in the .m file, adds private methods, ivars and properties, and can redeclare a public readonly property as readwrite.
Name clash: the runtime puts category methods in front of the class's own list, so a category method silently shadows the original. With two categories the winner depends on load order, which is undefined in practice. That is why category methods get a prefix: xyz_setBadgeText:.
Static libraries: the linker drops object files that contain only categories unless you pass -ObjC, and the call then fails at run time with "unrecognized selector".
Swift: an @objc member in a Swift extension of an Objective-C class is emitted as a category method and can clash the same way; a non-@objc extension method is resolved at compile time and cannot.
+load runs eagerly, once for every class and category that implements it, when the binary is loaded and before main(); +initialize runs lazily, just before the first message to the class, and is inherited. Swift rejects both overrides with a compile error.
**+load**: called directly by the runtime, not through objc_msgSend, so it is not inherited and a category's +load does not replace the class's. Superclasses run before subclasses, classes before their categories. Other classes may not be ready yet, and every +load adds to pre-main launch time.
**+initialize**: sent through objc_msgSend and thread-safe: other threads wait until it returns. A subclass without its own +initialize triggers the superclass's again, hence the self == [Foo class] guard; a category's +initialize replaces the class's.
Classic use: +load was the place to swizzle because it runs once before anything else; +initialize sets up class-level state on first use.
Swift: override class func load() fails with "method 'load()' defines Objective-C class method 'load', which is not permitted by Swift", and initialize() gets the same error. Call an explicit setup function from application(_:didFinishLaunchingWithOptions:), or use a static let whose initializer runs exactly once.
They tell the compiler how to synthesise the accessors: strong retains, weak does not and becomes nil, copy stores a copy, assign stores the raw value, and atomic (the default) makes each single get or set atomic. That is not thread safety: visits += 1 stays unprotected.
**strong: the default for objects, a +1 reference.
weak**: no +1; the runtime sets it to nil when the object is deallocated.
**copy**: the setter stores [value copy]. Used for NSString, NSArray and NSDictionary, so a mutable subclass passed in cannot change your value later, and for blocks.
**assign**: a plain store, for scalars such as NSInteger or CGFloat. On an object pointer it means unsafe_unretained: no retain and no zeroing, so it dangles once the object is freed.
**atomic vs nonatomic**: an atomic getter never returns a half-written value, at the price of a lock on every access. Almost every iOS property is nonatomic: the cost buys little.
**Why atomic is not enough**: self.visits += 1 is a get and a set, two atomic calls with a gap between them, so concurrent increments overwrite each other (ten queues adding 10,000 each ended near 20,000 in a test). Protect the whole operation with @synchronized, a lock or a serial queue; in Swift, with Mutex or an actor.
Extensions and categories cannot add storage, so you use associated objects: objc_setAssociatedObject stores a value against an object and a unique key in a side table, and the runtime releases it when the object is deallocated.
Key: any unique, stable address: a private global or static variable passed as &key, or a selector in Objective-C. The value stored at that address is never read.
Policy: .OBJC_ASSOCIATION_RETAIN_NONATOMIC for objects, COPY_NONATOMIC for strings and blocks, the atomic variants if several threads touch the value. ASSIGN is unsafe_unretained, not weak: once the target dies you read a dangling pointer, so put a weak reference in a box object instead.
Storage: a global hash map inside the runtime, guarded by a lock and keyed by the object's address; the object's isa gets a has_assoc bit so deallocation knows to clean up.
Costs: a lock and two hash lookups per access, values boxed as objects (a Swift struct becomes an opaque __SwiftValue), only class instances can hold them, and the dependency is invisible to anyone reading the class.
Prefer: a subclass, a wrapper or a side table owned by your own code. Use associated objects when you really must decorate a framework class, such as a badge on any UIView.
A block retains every object it captures, so a block stored by self that mentions self is a retain cycle; capturing __weak weakSelf breaks it, and turning it into strongSelf inside keeps the object alive for the rest of that run of the block.
**Why strongSelf**: a __weak pointer can become nil between two lines when the last owner releases it on another thread, so the block could apply half an update. One strong local makes the block all-or-nothing, and if (!strongSelf) return; exits early when the screen is already gone.
Hidden captures: using an ivar such as _items means self->_items, so it captures self strongly even though self never appears; -Wimplicit-retain-self warns about it.
**__block**: lets the block modify a captured local; the variable moves into a heap box shared by the block and the stack frame. Under ARC a __block object variable is strong, so unlike in manual retain/release it does not break a cycle.
Copying: blocks start on the stack and ARC copies them to the heap when they are stored or returned, which is why block properties are declared copy.
Swift: the same pattern is [weak self] plus guard let self else { return }.
Annotate the header so the importer can produce Swift-native types: nullability turns T! into T or T?, lightweight generics turn NSArray * into [String], and NS_SWIFT_NAME and NS_REFINED_FOR_SWIFT fix names and shapes that do not translate well.
Nullability: wrap the header in NS_ASSUME_NONNULL_BEGIN/END and mark exceptions nullable; unannotated pointers arrive as T!. A nonnull method that returns nil crashes far from the cause, so treat annotations as a contract.
Generics: NSArray<NSString *> * imports as [String], a plain NSArray * as [Any].
Enums: NS_ENUM becomes an open Swift enum, and a Swift 6 switch over it needs @unknown default; NS_CLOSED_ENUM makes it frozen; NS_OPTIONS becomes an OptionSet; NS_ERROR_ENUM gives a catchable error type.
**NS_SWIFT_NAME: renames a type, method or C function.
NS_REFINED_FOR_SWIFT**: imports the member with a __ prefix so you can wrap it in a nicer Swift API in an extension.
Concurrency: NS_SWIFT_UI_ACTOR, NS_SWIFT_SENDABLE and NS_SWIFT_NONISOLATED tell Swift 6 strict checking what the class promises. A method with a completion handler is also imported as an async function.
Before crashing with "unrecognized selector sent to instance", the runtime gives the object three chances: add a method (resolveInstanceMethod:), name another object to handle it (forwardingTargetForSelector:), or take the whole call as an NSInvocation (forwardInvocation:).
1. Dynamic resolution: +resolveInstanceMethod: can class_addMethod an implementation on the spot; it is cached, so the next call is a normal send. Core Data's @dynamic properties work this way.
2. Fast forwarding: -forwardingTargetForSelector: returns another object and the runtime re-sends the same message to it. Cheap; the classic tool for delegate proxies.
3. Full forwarding: -methodSignatureForSelector: plus -forwardInvocation: hand you the call as an object you can change, record or send to several targets. Slow, and NSInvocation is unavailable in Swift, so this stage needs Objective-C.
4. Failure: -doesNotRecognizeSelector: raises NSInvalidArgumentException, the familiar "unrecognized selector" crash. Typical causes: a renamed action still wired in a storyboard, a category dropped by the linker, an object of the wrong class after a bad cast.
**respondsToSelector:**: override it in a proxy as well: UIKit checks optional delegate methods before calling them, and the proxy must report the ones it forwards.
Swizzling swaps the implementations behind two selectors at run time, so every call to, say, viewWillAppear: runs your code first. It works only for methods dispatched through objc_msgSend and changes behaviour for the whole process.
Doing it safely
Run it exactly once per process: dispatch_once inside +load in Objective-C, a static let initializer in Swift. A second exchange swaps the methods back.
Swizzle the class that declares the method, or first class_addMethod the original onto your subclass; exchanging an inherited Method changes the superclass and every other subclass.
Inside the replacement, call the replacement's own selector: after the exchange it points to the original IMP. In Swift mark it @objc dynamic, otherwise Swift may call the replacement body directly and recurse forever.
Always call the original, keep the replacement short, and touch only public, documented methods.
Why it is risky
The original runs with your selector as _cmd, which breaks code that relies on _cmd.
Several SDKs swizzling the same method stack up in load order, and one that swizzles twice can break the chain for all of them.
Behaviour no longer matches the source, so stack traces and debugging mislead the next developer.
Pure Swift methods, final members and SwiftUI views cannot be swizzled, and Apple can change the internal call paths you hook in any iOS release.
Prefer subclassing, delegation, a proxy or an official hook; keep swizzling for analytics, debugging and testing tools.
Every object starts with an isa field that points to its class; objc_msgSend reads it, looks the selector up in that class's method cache, and on a miss walks the method lists up the superclass chain, caches the IMP it finds and jumps to it.
The lookup
nil and tagged pointers are checked first. Tagged pointers (small NSNumber, NSString, NSDate) keep their value inside the pointer and have no real isa.
The cache is a small per-class hash table from SEL to IMP. A hit costs a handful of instructions of hand-written assembly, and it is the common path.
On a miss, lookUpImpOrForward searches the class's method lists (categories included), then each superclass, stores the result in the receiver class's cache, and falls back to resolution and forwarding if nothing is found.
objc_msgSend ends with a jump, not a call: the arguments stay in their registers, so one trampoline serves every signature. That is why calling it from C needs a cast to the exact function type, and Swift cannot call it directly.
Adding methods, attaching categories or swizzling flushes the affected caches.
Classes and metaclasses
A class is an object too: its isa points to its metaclass, and class methods are instance methods of the metaclass.
Every metaclass's isa points to the root metaclass, whose superclass is NSObject itself, so a class-method lookup can end in an NSObject instance method.
Non-pointer isa
On arm64 isa is a bit field: besides the class pointer it holds an inline retain count (extra_rc) and flags such as weakly_referenced, has_assoc and deallocating. Most retain/release calls are one atomic operation on isa, without the side table.
So never read isa directly; use object_getClass.
It prints progress 0.5 and then status done. KVO replaces the object's class with a generated subclass whose setters send notifications, and only @objc dynamic makes Swift call a setter through objc_msgSend, where that subclass can catch it.
What KVO does
On the first observer the runtime creates a subclass named NSKVONotifying_ plus the class name and sets the object's isa to it. This is isa-swizzling.
The subclass overrides each observed setter: willChangeValueForKey:, the original setter, didChangeValueForKey:. It also overrides class to return Upload, so type(of:) hides the trick, while object_getClassName shows it.
Why each line behaves as it does
upload.progress = 0.5: the property is dynamic, so Swift sends setProgress: and the overriding setter notifies.
upload.status = "running": @objc without dynamic; Swift calls its own setter through the vtable and never asks the runtime, so nothing fires. The compiler even warns: "passing reference to non-'@objc dynamic' property 'status' to KVO method".
setValue("done", forKey: "status"): KVC looks up setStatus: by name through the runtime, reaches the override, and the observer fires.
In practice
Mark every observable property @objc dynamic, and keep the NSKeyValueObservation tokens (a, b) alive: releasing one removes its observer.
KVO remains the way to watch Apple types such as AVPlayer.timeControlStatus or WKWebView.estimatedProgress. For your own models, @Observable or @Published are type-safe and do not depend on the Objective-C runtime.
The swizzle runs on every scene connection instead of once per process: the second window exchanges the two implementations back, so viewDidAppear is the original again and tracking silently stops, until a third window turns it on again.
Why: method_exchangeImplementations is a toggle, not a set. scene(_:willConnectTo:options:) runs for every new scene session: each extra window on iPad, an external-display scene, a scene reconnected after the system discarded it.
Why it is hard to see: nothing crashes. An iPhone usually has one scene, so the bug shows mostly on iPad and in multi-window setups (and after a scene is discarded and reconnected), and the dashboard just shows fewer screen views.
The fix: put the exchange in a static let initializer, which Swift runs exactly once and thread-safely (swift_once, the counterpart of dispatch_once), and trigger it from application(_:didFinishLaunchingWithOptions:), which runs once per process.
The recursive-looking call is correct: after the exchange, the tracked_viewDidAppear(_:) selector points to UIKit's original implementation. It must stay @objc dynamic so Swift sends it as a message instead of calling the body directly.
Base class only: UIViewController declares viewDidAppear(_:) itself, so exchanging there is fine; for a subclass that only inherits the method, add it with class_addMethod first.
Toll-free bridged pairs such as CFStringRef and NSString share one memory layout, so a cast reuses the same object. ARC does not manage CF objects, so in Objective-C the bridge cast, and in Swift Unmanaged, states who owns the +1 reference.
Pairs: CFString/NSString, CFArray/NSArray, CFDictionary/NSDictionary, CFData/NSData, CFURL/NSURL, CFDate/NSDate and more. The cast is free. Swift's String is not toll-free: turning it into a CFString goes through the NSString bridge.
**__bridge: a cast with no ownership change, for a CF object you only borrow.
__bridge_transfer / CFBridgingRelease**: the CF object came from a Create or Copy function (+1); ARC takes it over and releases it.
**__bridge_retained / CFBridgingRetain**: hands an Objective-C object to CF code at +1; you must CFRelease it later.
Swift: Apple's CF APIs are annotated, so their results are managed automatically. Unannotated C APIs return Unmanaged<T>: takeRetainedValue() for the Create/Copy rule, takeUnretainedValue() for the Get rule. The wrong choice is a leak or an over-release crash.
Context pointers: passing self to a C callback as void * uses Unmanaged.passUnretained(self).toOpaque(), and then you must unregister before deinit; with passRetained you must balance it with release().
Swift's String stores UTF-8 and counts Characters (grapheme clusters), while NSString works in UTF-16 code units, so a bridge or an index conversion can mean transcoding and an O(n) walk, and text.count is not the length NSRange expects.
String to NSString: as NSString, or passing it to an Objective-C API, wraps the native buffer without copying, but UTF-16 calls on it (length, characterAtIndex:) must translate offsets; Swift keeps breadcrumbs so this stays cheap on large strings after the first walk.
NSString to String: the runtime calls copy (free for an immutable string) and the String keeps it lazily; later Swift operations go through the NSString API and are slower than on a native string. makeContiguousUTF8() converts once before heavy work.
Counts differ: in the example count is 12 and utf16.count is 16: the decomposed é is two UTF-16 units and the emoji with a skin-tone modifier is four, yet each is one Character. An NSRange of length count stops short and never reaches "rocks".
Ranges: build them with NSRange(text.startIndex..., in: text) and convert back with Range(match.range, in: text); never feed Int offsets from one world into the other.
Hot loops: count and index conversions walk the string on every call, and a String that came from NSString keeps going through the NSString API; compute the length or range once, or call makeContiguousUTF8() before the loop.
New code is Swift, but Objective-C still earns its place for catching NSException, runtime work such as forwardInvocation: and +load, Objective-C++ glue to C++ that Swift's interop cannot import, and the large legacy modules and SDKs that already work.
Where Objective-C is still needed
**NSException**: Swift cannot catch it. A tiny Objective-C helper with @try/@catch wraps calls that raise, such as NSPredicate(format:) with a user-supplied format or removeObserver:forKeyPath: for an observer that was never added.
Runtime work: NSInvocation, forwardInvocation: and +load are unavailable in Swift.
C++: Swift's C++ interop (Swift 5.9+) imports a lot, but not every template or ownership pattern; an Objective-C++ adapter remains the safe bridge.
Existing code: a mature module that works and rarely changes; rewriting it brings risk without user value.
Migration plan
Annotate first: nullability, generics, NS_SWIFT_NAME, NS_SWIFT_UI_ACTOR and NS_SWIFT_SENDABLE, so Swift callers get honest types before any file is rewritten.
@objc @implementation extension Foo (Swift 6.1, SE-0436): rewrite one Objective-C class or category in Swift while the .h file and all Objective-C callers stay unchanged; the compiler checks that the Swift members match the header.
Write new features in Swift inside the same target and convert leaf classes first. Objective-C cannot subclass a Swift class, so classes that Objective-C code subclasses go last.
Keep the Objective-C-facing API @objc until the last Objective-C caller is gone, then drop @objc to get static dispatch and a smaller binary back.
Convert one class per pull request with tests around its behaviour, and watch launch time and binary size; a big-bang rewrite of a working module is the usual way to bring old bugs back.