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:
__Self__ClassNamefor@implementation MyClass
Categories may also have class properties with a synthesised struct:
__Self__ClassName__CategoryNamefor@implementation MyClass (Foo)
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:
- Keyword:
Self— user-facing, scoped to@implementation - Underlying symbol:
__Self__ClassName— double-underscore, compiler-owned, never conflicts with user-writtenstatic Selfstruct - Mutex field:
__lock— double-underscore, compiler-internal; recursive mutex - Property fields: bare name matching the property (no
_prefix)
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:
- The base class
Selfstruct already exists as its own file-static in its own translation unit — there is nothing to copy into the subclass. - Class properties typically represent per-class state (registries, singletons,
configuration). Silently sharing a base-class field would cause
[Animal name]and[Bear name]to clobber each other — almost never the intent. - The existing manual
static Selfidiom in mulle-objc already works this way: each class has its own independent struct; nobody expects subclass 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__SubClassfield — 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/+deinitializeSelflifecycle entries.
[super name]in a manually-written subclass accessor works exactly as expected:superdispatches 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
+initializeSelfordering follows superclass-first (like+load), a subclass+initializeSelfcan safely seed its ownSelffrom 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 —
Selfas a first-class ObjC object (isa patching)The
__Self__ClassNamestruct can be given an ObjC object header, making(id)Selfa valid ObjC object passable toid-typed APIs. This follows the same technique used for@""constant string literals:
- Compiler emits
__Self__ClassNamewithisa = 0(placeholder), exactly like__NSConstantStringin the binary._mulle_objc_loadclassgains aselfStoragefield 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
Selfpointers (analogous touniverse->staticstrings) to support bulk re-patching if needed.Why isa stays valid during teardown:
__Self__ClassNamelives in the image's.datasegment. As long as the image is mapped, the struct exists and itsisafield is valid — including during+deinitializeSelf. The metaclass is not freed until after+deinitializeSelfcompletes.Image unload / dangling pointer: if external code retains
(id)Selfacross a dylib unload, that pointer into the now-unmapped.datais 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)Selfacross 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
Selfa pointer into the live metaclass object, so(id)Selfis a real ObjC object that can be passed toid-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];
- Synthesized only when
__lockexists (i.e. ≥1 atomic class property) - Suppressed per-method if the user provides their own
+lock/+unlock/+tryLock - Not generated for
nonatomic-only classes (no__lockto expose)
+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:
- Main
@implementation MyClassregisters its owninitializeSelfentry @implementation MyClass (Foo)registers a separate entry- The runtime calls all registered entries at class setup/teardown time, not just the "winning" override from normal method dispatch
- A category's
+initializeSelfnever shadows the main class's
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.
- If the user provides
+initializeSelf/+deinitializeSelfin a block, synthesis for that block is suppressed — the user is responsible for lock init/done and nil-ing properties nonatomic-only blocks:+deinitializeSelfstill synthesized (to nil/release fields), no lock operations, no+initializeSelf
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):
initializeSelf_list— entries called before+initialize, in load orderdeinitializeSelf_list— entries called after+deinitialize, in reverse load order
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) #
-
EmitPropertyList: Addbool IsClassPropertyparameter. FilterOCD->properties()byPD->isClassProperty() == IsClassProperty. Call twice — once for infraclass, once for metaclass. -
__Self__ClassNamestruct emission: InGenerateImplementation, collect all class properties for the@implementation. Emit:1@__Self__MyClass = internal global %struct.__Self__MyClass zeroinitializer -
Accessor synthesis: For each class property without a user-provided accessor, emit the getter/setter class methods using
__Selffield access- conditional locking.
-
+initializeSelf/+deinitializeSelfsynthesis: Emit as image metadata entries (like+load) — one entry per@implementationblock 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 #
- Re-declaration in subclasses: exact type-compatibility and attribute rules are TBD (see Idea note above).
+initializeSelfsuperclass-first ordering: needs to be specified in runtime.[Self msg]in categories (no struct):Self= infraclass — confirm compiler handles cleanly.