I ran into tagged pointers through an allocation mystery. A test loop created thousands of boxed numbers, but Instruments showed far fewer heap allocations than I expected. At first I assumed the optimizer had removed my code. The values were definitely being consumed, though, and the count still looked “wrong.”
Logging a few object addresses led me to tagged pointers. Some Objective-C values were living directly in the pointer-sized word instead of in separately allocated heap objects. That explained the missing allocations—and also exposed several bad assumptions I had made about what an object pointer must look like.
This is the experiment I use to explain the idea now. The exact encoding is private and can change, so I focus on what the behavior teaches us rather than memorizing bit layouts.
Why My Allocation Count Looked Wrong
A conventional object needs heap storage for its header and payload. Creating it involves allocation bookkeeping, initialization, later reference-count traffic, and eventual deallocation. For a tiny numeric value, the metadata can cost more memory and work than the value itself.
A 64-bit pointer has enough bit patterns to represent more than aligned heap addresses. The runtime can reserve patterns that signal “this word contains an encoded object.” Selected bits identify a tagged representation, while other bits carry the payload. Message sending recognizes the tagged form and dispatches to the appropriate class behavior without reading an isa from heap memory.
The optimization is transparent at the API level. You still hold an id, send messages, store it in collections, and compare values using documented methods. The difference becomes visible mainly in allocation tools and runtime experiments.
The Small Experiment I Ran
Create several boxed numbers and strings, then print their pointer values and inspect allocations with Instruments:
NSArray *values = @[
@1,
@2,
@42,
@123456789,
@"a",
@"a somewhat longer string"
];
for (id value in values) {
NSLog(@"%@: %p class=%@",
value,
(__bridge void *)value,
object_getClass(value));
}
You may observe that some values have addresses unlike ordinary aligned heap allocations and do not appear as separate allocations. Results can change with architecture, OS version, process configuration, and value range. The experiment demonstrates behavior on one environment; it is not a table of stable encoding rules.
Run the test in the same build mode and device family you are investigating. Simulator and physical-device results may differ. Avoid drawing conclusions from one literal because compilers and frameworks may intern or optimize constants.
The Part That Surprised Me: Messages Still Work
objc_msgSend cannot blindly dereference every receiver to find a class. The runtime identifies tagged patterns and resolves the corresponding class through its tagged-pointer machinery. From the caller’s perspective, integerValue, description, equality, and collection behavior work normally.
This is a strong example of abstraction in Objective-C. The language presents object semantics while the runtime chooses more than one physical representation. Code written against documented methods remains portable across those representations.
Problems appear when code assumes representation: manually reading an isa, calculating object size from a pointer, copying raw object bytes, or treating every pointer as a valid heap address. Those techniques are unsafe even without tagged pointers, and tagged values expose the assumption quickly.
What This Changed in My Ownership Mental Model
A tagged value has no separate allocation whose lifetime must be extended. The runtime can make retain and release operations effectively no-ops for that representation. Under ARC, source code continues to use strong and weak references according to ordinary ownership rules, while runtime internals optimize the case.
Do not use this fact to remove ownership qualifiers or invent special cases. A value that is tagged today may use a heap representation when its payload changes, or on another OS. Your property must remain correct for either representation.
Weak references deserve particular care in experiments. Their observable behavior should be understood through documented weak-reference semantics, not through assumptions about whether a particular object happens to be tagged. Test the API contract you need, not a private storage shortcut.
Why I Stopped Judging Objects by Their Addresses
Developers sometimes use address shape as proof that two objects are identical or that an object is “corrupted.” Tagged pointers can make addresses look unusually small, unusually large, or patterned. Address logging is only one clue.
Use isEqual: for value equality and == only when identity is the intended question. Even then, interning and tagged encodings can make equal literals share representations in ways you should not depend on. For numeric code, compare the numeric values.
LLDB may display tagged objects differently from heap objects. Prefer object-aware commands and method calls over raw memory reads. If a debugger command tries to dereference the receiver as ordinary storage, the output may be meaningless or the read may fail.
How I Re-ran the Measurement More Carefully
Suppose a loop creates one million boxed small integers, but the Allocations instrument reports far fewer object allocations than expected. Tagged pointers may explain part of the gap. Constant folding, autorelease optimization, and compiler behavior can also contribute.
Design a controlled benchmark. Generate values at runtime, consume the results so the optimizer cannot remove the work, and separate creation from collection storage. Compare small and large values while keeping the surrounding code identical. Record the device, OS, architecture, and build configuration.
Do not publish universal performance claims from this microbenchmark. The useful result is a diagnostic explanation for the tested configuration. Application performance still depends on algorithms, collection behavior, bridging, and surrounding allocations.
Bridging Between Swift and Objective-C
Swift values bridged to Foundation may use internal representations chosen by the runtime. A Swift Int converted to NSNumber can behave like any other Objective-C object at the API boundary, while its storage representation remains an implementation choice.
Avoid code that uses pointer identity to infer whether two bridged values came from the same Swift value. Likewise, do not expose tagged-pointer assumptions in serialization, persistence, or hashing. Use Foundation’s documented conversion and value APIs.
When profiling mixed-language code, remember that bridging may create temporary objects even if some individual values are tagged. Measure the complete operation rather than assuming “tagged” means “free.” Dispatch, conversion, and collection work still have costs.
Assumptions I No Longer Make
Myth: every small number is always tagged. Safer conclusion: the runtime may choose tagged representations for selected values, but classes and ranges are implementation details. Myth: tagged pointers eliminate all cost. Safer conclusion: they can avoid a heap allocation, while message dispatch and surrounding operations still cost time.
Myth: address patterns are a supported API. Safer conclusion: pointer logs can support a debugging hypothesis, but product logic must use documented object behavior. Myth: retaining a tagged pointer is a bug. Safer conclusion: normal Objective-C ownership operations remain correct and the runtime handles the representation.
What I Keep in Mind During Debugging
- Never assume every Objective-C object has readable heap storage.
- Use documented methods for equality, conversion, and ownership.
- Treat participating classes, value ranges, and bit layouts as private details.
- Compare behavior on the actual architectures and OS versions you support.
- Use Instruments to validate allocation hypotheses.
- Keep benchmarks controlled and record their environment.
- Do not make persistence or business logic depend on pointer identity or address shape.
- Measure bridging as a complete operation.
The lasting lesson for me was not how to decode a tagged pointer. It was to stop treating one memory representation as the definition of an Objective-C object. The safest code respects that flexibility: program to object semantics, use runtime details to explain measurements, and keep private encodings out of product logic.
Where I Checked the Runtime Details
Apple’s open-source Objective-C runtime contains the implementation-level tagged-pointer machinery, available through Apple Open Source: objc4. Public runtime functions are documented in Objective-C Runtime. Exact encodings are deliberately not specified as a stable application contract, so repeat experiments on supported targets and avoid copying private bit layouts into shipping code.
Download the Complete Runnable Project
The complete code for this article is available in its own GitHub repository: https://github.com/2252408699/objc-tagged-pointers-demo.
Download it with git clone, then follow the repository README to build and run the example locally.
