Class Property Proposal for mulle-objc

· nat's blog


Summary #

Add support for @property (class, ...) in mulle-objc. Class properties are stored in a compiler-generated file-static struct accessed via the new Self keyword (analogous to self). Thread safety is automatic for atomic properties (the default); nonatomic opts out.


Syntax #

1@interface MyClass : NSObject
2@property (class, copy)           NSString *sharedString;   // atomic (default)
3@property (class, nonatomic, copy) NSString *debugLabel;    // nonatomic
4@end

No dot-syntax. Access is always via message send:

1[MyClass setSharedString:@"hello"];
2NSString *s = [MyClass sharedString];

Generated Code (Compiler Synthesis) #

Self — the class-side keyword #

Self (capital S) is a new mulle-objc keyword, scoped to @implementation blocks. It is always defined — its meaning depends on whether the class has synthesised class property storage:

Situation Self Notes
Class properties declared __Self__ClassName struct (static global) isa → metaclass once "Self as object" idea is adopted
No class properties infraclass (= self in + methods, object_getClass(self) in - methods) infraclass->isa = metaclass

In both cases Self is a valid ObjC object whose isa points to the metaclass, so [Self someClassMethod] dispatches correctly through the metaclass chain regardless of whether a storage struct exists.

Self.field (dot-notation field access) is only valid when class properties are declared — the fields are synthesised fields of __Self__ClassName. Without class properties there are no fields; a Self.field access is a compile error.

Context self Self
Instance method (-) current object __Self__ClassName struct, or object_getClass(self) if no properties
Class method (+) current class (infraclass) __Self__ClassName struct, or self if no properties

Outside an @implementation block, Self is undefined.

Implementation: modelled directly on self — the compiler implicitly introduces Self as a name in ObjC implementation scope. When class properties exist it resolves to the mangled static variable (__Self__ClassName or __Self__ClassName__CategoryName). When no class properties exist it resolves to self (in + methods) or object_getClass(self) (in - methods) — the infraclass in both cases.

Self is the struct value (not a pointer): Self.field and &Self.field work as plain C struct member access on the global. When Self appears as an ObjC message receiver ([Self msg]), the compiler implicitly takes its address and casts to id, so message dispatch works without user ceremony.

Storage — __Self__ClassName struct #

For each main-class @implementation that has class properties, the compiler emits a file-static struct with a mangled name:

Categories may also have class properties with a synthesised struct:

Note: category synthesis is only possible with the file-static struct approach. If the "metaclass ivar" future direction is adopted, category class property synthesis becomes impossible (categories cannot extend an already-allocated object); see the Idea note below.

 1// synthesized for @implementation MyClass  (Self resolves to this)
 2static struct {
 3    mulle_thread_recursive_mutex_t   __lock;      // compiler-internal; only if ≥1 atomic property
 4    NSString                         *sharedString;
 5    NSString                         *debugLabel;
 6} __Self__MyClass;
 7
 8// synthesized for @implementation MyClass (Foo)  (Self resolves to this inside that block)
 9static struct {
10    NSString               *extraProp;
11} __Self__MyClass__Foo;

Naming conventions:

The __lock field is emitted only if at least one class property in that @implementation block is atomic. nonatomic-only blocks get no __lock.

Inheritance — no storage composition #

Self contains only the properties declared in that @implementation block. There is no composition of base-class fields into the subclass struct — unlike instance ivars, where the subclass layout includes superclass fields at the front.

Comparison with Apple's clang: Apple's @property (class, ...) synthesises only the accessor method declarations; backing storage is always provided manually by the programmer. Apple therefore never had to decide this question. mulle-objc goes further by synthesising the backing struct, making the inheritance policy a real design choice.

Why no composition:

Accessing base-class class properties from a subclass: Use normal ObjC message dispatch — the accessor methods are inherited through the metaclass chain like any other class method:

1// Animal declares @property (class) NSMutableArray *allAnimalClasses;
2// Bear +initialize — Bear has no Self field for allAnimalClasses:
3[[self class] allAnimalClasses];    // dispatches to Animal's synthesised getter

Self.field is only valid for fields that this @implementation block declared. Inherited state lives in the base class's own Self and is reached through message dispatch.

Idea — re-declaration in subclasses

A subclass may re-declare a base class's @property (class, ...) in its own @interface. This creates an independent __Self__SubClass field — completely separate storage from the base class's field. The subclass owns its own value, can choose any memory attribute (even different from the parent), and gets its own +initializeSelf/+deinitializeSelf lifecycle entries.

[super name] in a manually-written subclass accessor works exactly as expected: super dispatches to the base class's synthesised getter, which reads from __Self__Animal.name. This enables value chaining:

1+ (NSString *) name {
2    return [[super name] stringByAppendingString:@" (Bear)"];
3}

If +initializeSelf ordering follows superclass-first (like +load), a subclass +initializeSelf can safely seed its own Self from the base value:

1+ (void) initializeSelf {
2    Self.name = [[super name] copy];   // Animal's +initializeSelf already ran
3}

Without re-declaration, [[self class] name] dispatches up to the base class getter and reads/writes base class storage — the right behaviour for shared registries. Re-declaration is opt-in, explicit, and never implicit. The exact rules (type compatibility, attribute constraints) are TBD.

Idea — Self as a first-class ObjC object (isa patching)

The __Self__ClassName struct can be given an ObjC object header, making (id)Self a valid ObjC object passable to id-typed APIs. This follows the same technique used for @"" constant string literals:

  • Compiler emits __Self__ClassName with isa = 0 (placeholder), exactly like __NSConstantString in the binary.
  • _mulle_objc_loadclass gains a selfStorage field pointing to the struct.
  • Runtime, immediately after creating the class pair, patches __Self__ClassName.isa = metaclass — the metaclass is the natural class of a class-side object, so [Self name] dispatches through the metaclass chain identically to [Bear name].
  • The universe may track all Self pointers (analogous to universe->staticstrings) to support bulk re-patching if needed.

Why isa stays valid during teardown: __Self__ClassName lives in the image's .data segment. As long as the image is mapped, the struct exists and its isa field is valid — including during +deinitializeSelf. The metaclass is not freed until after +deinitializeSelf completes.

Image unload / dangling pointer: if external code retains (id)Self across a dylib unload, that pointer into the now-unmapped .data is dangling — the same contract that applies to @"" constant strings. The runtime does not specifically protect against this for constant strings, and the same assumption applies here: do not hold (id)Self across image unload.

This approach is noted here as a potential v2 upgrade path. The metaclass ivar alternative (appending ivar bytes to the metaclass object) is also viable since subclass composition is not required and only one instance is ever created; see below.

Idea — metaclass ivar storage (future direction)

An alternative to the file-static struct is to store class property data as ivar bytes appended directly to the metaclass object (analogous to how instance ivars are appended to the infraclass instance). This would make Self a pointer into the live metaclass object, so (id)Self is a real ObjC object that can be passed to id-typed APIs and introspected via normal runtime reflection.

This is simpler than it sounds for class properties because:

  • Only one metaclass object is ever created (no per-instance allocation).
  • There is no ivar composition across subclasses — each class's storage is independent.

Why category class properties cannot be ivar-backed (regardless of approach): Categories cannot add instance ivars to an existing class — the class object is already allocated with a fixed size when a category is loaded. The same constraint applies to metaclass ivars: a category cannot extend the metaclass object that was allocated when the main class was registered. This rule is consistent with existing ObjC: if you want synthesised storage, declare the property in the main @interface/@implementation. Categories may declare @property (class, ...) but must provide manual accessor implementations.

With category synthesis disallowed, the metaclass-ivar model becomes fully uniform — no special cases. This approach is noted here as a potential v2 upgrade path.

Why a recursive mutex #

__lock is a data lock — its sole job is to protect __Self__ClassName fields from concurrent thread access. It is not a critical-section lock guarding complex invariants.

A non-recursive mutex would deadlock if a method that manually holds __lock also calls a synthesized atomic accessor (which tries to take the same lock). This is a normal and expected pattern when doing multi-field updates:

1+ (void) setFooAndBar:(NSString *)x y:(NSString *)y {
2    mulle_thread_recursive_mutex_lock(&Self.__lock);
3    Self.foo = [x copy];
4    [self setBar:y];   // synthesized accessor re-enters __lock — OK with recursive
5    mulle_thread_recursive_mutex_unlock(&Self.__lock);
6}

With a non-recursive mutex the [self setBar:y] call would deadlock on the calling thread — strictly worse than allowing re-entry. Re-entrant access from the same thread does not violate the data protection invariant; other threads still block. The overhead of a recursive mutex over a non-recursive one is negligible for this use case.

Accessor Synthesis #

Synthesis is suppressed for any accessor the user provides (same rule as instance properties). The __Self__ClassName struct is always generated and its fields are accessible within the @implementation for manual accessors.

Atomic getter/setter (copy example):

 1+ (NSString *) sharedString {
 2    mulle_thread_recursive_mutex_lock(&Self.__lock);
 3    NSString *v = Self.sharedString;
 4    mulle_thread_recursive_mutex_unlock(&Self.__lock);
 5    return v;
 6}
 7+ (void) setSharedString:(NSString *)value {
 8    mulle_thread_recursive_mutex_lock(&Self.__lock);
 9    NSString *old = Self.sharedString;
10    Self.sharedString = [value copy];
11    [old autorelease];
12    mulle_thread_recursive_mutex_unlock(&Self.__lock);
13}

Nonatomic getter/setter (copy example):

1+ (NSString *) debugLabel {
2    return Self.debugLabel;
3}
4+ (void) setDebugLabel:(NSString *)value {
5    NSString *old = Self.debugLabel;
6    Self.debugLabel = [value copy];
7    [old autorelease];
8}

Setter memory semantics follow the property attribute:

Attribute Setter action
copy [value copy], release old
retain [value retain], release old
assign direct assign, no release
readonly no setter generated

+lock / +unlock / +tryLock #

When at least one atomic class property exists, the compiler also synthesizes three class methods that expose the recursive mutex as a clean ObjC API:

1+ (void) lock    { mulle_thread_recursive_mutex_lock(&Self.__lock); }
2+ (void) unlock  { mulle_thread_recursive_mutex_unlock(&Self.__lock); }
3+ (BOOL) tryLock { return mulle_thread_recursive_mutex_trylock(&Self.__lock) == 0; }

This allows compound atomic operations from anywhere — inside or outside the implementation — without exposing the C mutex:

1[MyClass lock];
2[MyClass setSharedString:@"foo"];   // synthesized accessor re-enters __lock fine
3[MyClass setDebugLabel:@"bar"];
4[MyClass unlock];

+initializeSelf / +deinitializeSelf #

These are not normal ObjC methods — they are registered in image metadata per class/category (exactly like +load) and called independently by the runtime for each entry. This means:

Synthesized implementations:

 1// for @implementation MyClass (atomic properties present)
 2+ (void) initializeSelf {
 3    mulle_thread_recursive_mutex_init(&Self.__lock);
 4}
 5+ (void) deinitializeSelf {
 6    // release/nil copy/retain fields; zero assign fields
 7    [Self.sharedString release];  Self.sharedString = nil;
 8    [Self.debugLabel release];    Self.debugLabel   = nil;
 9    mulle_thread_recursive_mutex_done(&Self.__lock);
10}
11
12// for @implementation MyClass (Foo) (nonatomic only — no lock)
13+ (void) deinitializeSelf {
14    [Self.extraProp release];  Self.extraProp = nil;
15}

Call order per class:

class setup:    +initializeSelf (all entries)  →  +initialize
class teardown: +deinitialize  →  +deinitializeSelf (all entries, reverse order)

+initializeSelf runs before +initialize so class properties are ready for user init code. +deinitializeSelf runs after +deinitialize so user teardown code can still access class properties. Multiple entries (main + categories) are called in load order; deinit in reverse.

assign fields are zeroed but not released. copy/retain fields are [old release] + nil.


Runtime Changes #

0. Add mulle_thread_recursive_mutex_t to mulle-thread #

mulle-thread currently has no recursive mutex type. NSRecursiveLock in MulleObjC implements recursive locking but as an ObjC class using @defs() — unusable from the runtime or compiler-generated C code.

Add to mulle-thread (mulle-thread.h / mulle-thread.c):

 1typedef struct {
 2    mulle_thread_mutex_t    _mutex;      // underlying non-recursive mutex
 3    mulle_atomic_pointer_t  _thread_id; // owning thread ID (or NULL)
 4    mulle_atomic_pointer_t  _depth;     // recursion depth counter
 5} mulle_thread_recursive_mutex_t;
 6
 7int   mulle_thread_recursive_mutex_init(mulle_thread_recursive_mutex_t *p);
 8void  mulle_thread_recursive_mutex_done(mulle_thread_recursive_mutex_t *p);
 9void  mulle_thread_recursive_mutex_lock(mulle_thread_recursive_mutex_t *p);
10void  mulle_thread_recursive_mutex_unlock(mulle_thread_recursive_mutex_t *p);
11int   mulle_thread_recursive_mutex_trylock(mulle_thread_recursive_mutex_t *p); // 0=acquired

The algorithm is already proven in NSRecursiveLock-Private.h — this is a straight extraction to pure C. Once this exists, NSRecursiveLock can be refactored to wrap mulle_thread_recursive_mutex_t instead of duplicating the logic.

1. Metaclass gets a property list #

Currently struct _mulle_objc_infraclass has propertylists; metaclass does not.

Add to struct _mulle_objc_metaclass (mulle-objc-runtime):

1struct mulle_concurrent_pointerarray   propertylists;

New functions (parallel to infraclass API):

 1int    mulle_objc_metaclass_add_propertylist(struct _mulle_objc_metaclass *meta,
 2                                             struct _mulle_objc_propertylist *list);
 3void   mulle_objc_metaclass_add_propertylist_nofail(...);
 4struct _mulle_objc_property *
 5       _mulle_objc_metaclass_search_property(struct _mulle_objc_metaclass *meta,
 6                                             mulle_objc_propertyid_t propertyid);
 7struct _mulle_objc_property *
 8       mulle_objc_metaclass_search_property(struct _mulle_objc_metaclass *meta,
 9                                            mulle_objc_propertyid_t propertyid);
10void   _mulle_objc_metaclass_walk_properties(...);

2. Runtime calls +initializeSelf / +deinitializeSelf like +load #

+initializeSelf and +deinitializeSelf are registered in image metadata per class/category — the same mechanism as +load. The runtime iterates all registered entries independently; they are never dispatched through normal method lookup (so a category's entry never overrides the class's).

New image metadata entries (parallel to existing +load list):

The runtime only needs to know the function pointer for each entry, not the selector — same as +load.


Compiler Changes #

Parser — no change needed #

@property (class, ...) already parses; kind_class attribute is recognized.

Sema — no change needed #

Accessor methods are already created as class methods when isClassProperty(). Accessor lookup already looks in class method list.

CodeGen (CGObjCMulleRuntime.cpp) #

  1. EmitPropertyList: Add bool IsClassProperty parameter. Filter OCD->properties() by PD->isClassProperty() == IsClassProperty. Call twice — once for infraclass, once for metaclass.

  2. __Self__ClassName struct emission: In GenerateImplementation, collect all class properties for the @implementation. Emit:

    1@__Self__MyClass = internal global %struct.__Self__MyClass zeroinitializer
    
  3. Accessor synthesis: For each class property without a user-provided accessor, emit the getter/setter class methods using __Self field access

    • conditional locking.
  4. +initializeSelf / +deinitializeSelf synthesis: Emit as image metadata entries (like +load) — one entry per @implementation block that has class properties. Suppressed if user provides the method in that block.


Property Metadata Structure #

No change to struct _mulle_objc_property. The existing isClassProperty() flag (bit 0x4000 in attributes) is already set by Sema. The metaclass property list carries class properties; infraclass carries instance properties. Runtime introspection tools distinguish them by which list they appear in.


Interaction with Existing Code #

Scenario Behaviour
User has static Self struct No conflict — compiler uses __Self__ClassName
User writes +sharedString Getter synthesis suppressed; Self.sharedString still exists
User writes +initializeSelf Lock-init synthesis suppressed; user owns Self.__lock
nonatomic only class No __lock, no +initializeSelf
No class properties declared Self = infraclass; Self.field is a compile error
Category adds class property Gets __Self__ClassName__CategoryName; no name clash in same TU
Duplicate class property name across categories Sema error (same as duplicate instance property in category)
readonly class property Only getter synthesized; no setter

Open Questions #

last updated: