Skip to content

Library Evolution

Austin edited this page Oct 30, 2026 · 1 revision

OpenNext / RhapsodiOS: Library Evolution Strategy

Purpose

Rhapsody OS and early versions of Mac OS X did not provide a comprehensive library-evolution mode. Library evolution is the ability to update a framework or library while preserving binary compatibility with existing clients. Source-compatible changes can still break the application binary interface (ABI), forcing clients to be recompiled.

OpenNext / RhapsodiOS (distinct from Apple's historical Rhapsody OS) should establish ABI-resilience conventions from day one. The goal is to allow frameworks to evolve without rebuilding applications that depend on them.

Core strategy: indirect instance-variable storage

For classes built with DistributionRhapsodiOS (alias DistributionOpenNext), store implementation-specific instance variables in a separate, privately defined object, rather than exposing them as part of the public class layout.

The public class contains one fixed-size pointer to private storage. New private fields can be added without changing the size of the public class. This is the Objective-C analogue of the pointer-to-implementation (PImpl) pattern.

// Public header
@interface ONDocument : NSObject {
@private
    void *_on_reservedStorage;
}

- (void)open;
@end
// Private implementation
@interface ONDocumentStorage : NSObject {
@public
    NSString *_path;
    BOOL _isOpen;
    // More fields can be added in later versions.
}
@end

@implementation ONDocumentStorage
@end

@implementation ONDocument

- (id)init {
    self = [super init];
    if (self != nil) {
        _on_reservedStorage = [[ONDocumentStorage alloc] init];
    }
    return self;
}

- (void)dealloc {
    [(id)_on_reservedStorage release];
    [super dealloc];
}

@end

The example uses manual reference counting for compatibility with historical Objective-C environments. Under ARC, ownership and cleanup would be expressed differently.

Important limitation: the public pointer itself is still part of the ABI

The indirection pointer must be present when the class is first published. Adding it later would itself change the class layout. Once shipped, its position and meaning must remain stable.

In addition, the private storage class is only layout-resilient if its layout remains genuinely private. Never publish its headers, allow client code to allocate it directly, or expose its fields through public inline functions.

Naming without superclass conflicts

A conventional ivar name such as _private, _reserved, or _storage is too generic: an ancestor or subclass may independently use the same name. Objective-C permits an inherited ivar and a subclass ivar to have distinct offsets, but repeated names can still cause source-level confusion, compiler restrictions, and maintenance problems.

Recommended convention: use a framework prefix, a class-specific identifier, and a fixed suffix:

_on_ONDocument_storage
_on_ONWindow_storage
_on_ONView_storage

For example:

@interface ONDocument : NSObject {
@private
    void *_on_ONDocument_storage;
}
@end

This is not a mathematical guarantee of uniqueness, but it greatly reduces accidental collisions. A compiler-generated name containing a stable, fully qualified class identity would be even safer for automatic synthesis.

An alternative is a single reserved pointer named using a class-specific opaque type:

typedef struct ONDocumentPrivate ONDocumentPrivate;

@interface ONDocument : NSObject {
@private
    ONDocumentPrivate *_on_ONDocument_private;
}
@end

This keeps the implementation hidden while providing stronger type checking than void *. Define the struct only in the implementation file:

struct ONDocumentPrivate {
    NSString *path;
    BOOL isOpen;
};

Recommendation: prefer a private C struct for simple state, and a private Objective-C object when the state needs its own lifecycle, polymorphism, or methods. Either approach preserves the public class size as long as the pointer remains fixed.

Subclassing considerations

A subclass must not assume that its superclass's private storage is accessible or that its fields have particular offsets. Each public subclass can declare its own private storage pointer from its initial release:

@interface ONWindow : NSObject {
@private
    void *_on_ONWindow_storage;
}
@end

@interface ONPanel : ONWindow {
@private
    void *_on_ONPanel_storage;
}
@end

Because the names are class-specific, the subclass does not need to reuse or override the superclass's storage ivar.

However, changing the size of an existing superclass can still break the layout of previously compiled subclasses. This is why the superclass's public layout—including its storage pointer—must remain frozen.

Distribution compilation mode

A proposed conditional compilation convention is:

#if defined(DistributionRhapsodiOS) || defined(DistributionOpenNext)
    // ABI-resilient declarations and implementation.
#else
    // Development configuration, if different.
#endif

The two macros are aliases for the same distribution policy. They should not select different public class layouts.

Strong recommendation: do not allow debug and distribution builds of the same published framework to disagree about public object layout. Prefer enabling resilient layout unconditionally for exported classes, including during development. Distribution mode can instead control ABI checks, symbol visibility, and compatibility validation.

A future SwiftletObjC compiler could synthesize a private storage pointer and redirect declared implementation-only properties to that storage automatically. For example, a compiler-specific attribute could opt a class into resilient storage, while ensuring that the emitted Objective-C class layout remains fixed. This would require compiler and runtime design work; it is not a feature of historical Objective-C compilers.

Other strategies to evaluate

1. Opaque handles for C APIs

Expose incomplete types and functions instead of public structures:

typedef struct ONContext ONContext;

ONContext *ONContextCreate(void);
void ONContextRelease(ONContext *context);

This is particularly effective for C-facing framework APIs. The implementation may change struct ONContext without affecting clients.

2. Accessor methods instead of public ivars

Expose methods or properties rather than implementation offsets. A method call is less dependent on class layout than direct ivar access. Avoid exposing private ivar offsets through inline accessors or macros.

3. Reserved expansion slots

An exported class or C structure may contain reserved pointers for future use. This can work, but it is less flexible than a single pointer to extensible private storage and creates questions about ownership and slot allocation.

4. Stable function and method entry points

Avoid removing exported symbols or changing the calling convention of existing functions. Add new entry points when behavior or parameter types need to change. For Objective-C, preserve existing selector names and method type/return conventions used by compiled clients.

5. Versioned APIs and capability checks

Provide explicit runtime version or feature checks so applications can use new functionality while remaining compatible with older framework versions. Prefer capability detection where possible.

6. Symbol visibility and export lists

Export only intended public ABI symbols. Keep helper functions, private classes, and internal globals hidden where supported by the target toolchain and loader. A symbol that becomes public accidentally can be difficult to remove later.

7. Stable enums, constants, and structures

Do not casually change enum values, public structure sizes, field offsets, packing, or alignment. For extensible C structures, consider a size/version field and documented initialization rules.

8. Protocol-based extension points

Expose stable protocols or delegate methods rather than requiring clients to depend on concrete implementation classes. Adding required protocol methods can itself break compatibility; new methods should generally be optional or introduced through a new protocol.

9. ABI manifests and automated comparison

Record exported classes, superclass relationships, ivar layouts, methods, function signatures, symbols, and calling conventions for each release. Compare every new build against the previous published release, and test old application binaries against new frameworks.

ABI hazards that private ivar storage does not solve

Indirect ivars are important, but not sufficient for full library evolution. Other breaking changes include:

  • Changing a public class's superclass or inheritance hierarchy.
  • Removing or renaming exported classes, selectors, functions, or global symbols.
  • Changing function parameter types, return types, structure passing, or calling conventions.
  • Changing public C struct layout, alignment, or packing.
  • Changing semantics on which existing applications depend.
  • Changing ownership conventions, including retained versus autoreleased returns.
  • Changing the runtime behavior of object construction, destruction, or method dispatch.
  • Introducing new dependencies unavailable on supported installations.

For historical Rhapsody-era Objective-C runtimes, verify the precise compiler/runtime behavior before relying on modern Objective-C features such as non-fragile ivars, associated objects, or ARC.

Recommended day-one policy

  1. Freeze public instance layouts. Every exported, subclassable Objective-C class gets a uniquely named private-storage pointer from its first release.
  2. Keep storage definitions private. Internal fields live in private structs or objects; never expose their layout through public headers.
  3. Use accessors and methods. Do not publish direct access to internal ivars.
  4. Avoid conditional public layouts. Debug and release variants must preserve the same ABI.
  5. Minimize exports. Publish only intentional classes, functions, and symbols.
  6. Treat subclassing as an ABI contract. Document which classes support external subclassing and which methods subclasses may override.
  7. Track the ABI. Generate a machine-readable ABI manifest for each released framework and compare it in continuous integration.
  8. Test binary compatibility. Keep compiled applications and plugins from older releases and run them unchanged against newer frameworks.
  9. Version deliberate breaks. If an incompatible change is unavoidable, introduce a new ABI boundary, framework version, or class/API rather than silently replacing the old one.

Suggested compiler-level evolution

For SwiftletObjC or another OpenNext-aware compiler, investigate an attribute such as:

__attribute__((on_resilient_class))
@interface ONDocument : NSObject
@property (nonatomic, copy) NSString *path;
@end

The compiler could synthesize a stable class-specific storage pointer and implement properties through indirect storage. This should be treated as a proposed language extension, not valid syntax on unmodified historical GCC or Clang.

The compiler must also define inheritance behavior, initialization and destruction, introspection, runtime ivar metadata, exception safety, and interaction with existing Objective-C binaries. It should never silently rewrite the layout of already published classes.

Decision to make first

Choose whether private storage is managed by each class or by the runtime.

  • Per-class pointer: straightforward, compatible with traditional Objective-C object layouts, efficient access, and independently owned storage. Recommended as the initial implementation.
  • Runtime-managed side table: avoids adding a pointer to each object but requires stable identity, synchronized lookup, cleanup, and careful handling of object lifetime. It may be useful later for special classes or dynamically attached state, but should not be the default without benchmarking and runtime validation.

Proposed default for OpenNext / RhapsodiOS: a class-specific, fixed-layout pointer to opaque private storage, backed by strict exported-ABI checks and binary compatibility tests from the first public release.