Class Properties — Specification (Option D)

· nat's blog


WORK IN PROGRESS #

Summary #

@property (class, ...) declares class-level state stored as instance variables of the infraclass, appended to the classpair allocation. No new keywords. Access via self->_ivar in + methods, or via synthesized accessors from anywhere. Categories require @property(dynamic) for class properties (accessor-only, no ivar). Class property ivars compose through inheritance exactly like instance ivars.


User-Facing Syntax #

 1@interface Foo : NSObject
 2@property (class, copy)   NSString *sharedName;
 3@property (class, assign) int       sharedCount;
 4@end
 5
 6@implementation Foo
 7
 8+ (void) reset
 9{
10   [self setSharedCount:0];
11   [self setSharedName:@"default"];
12}
13
14@end

From instance methods:

1- (void) printShared
2{
3   // self is an instance — no direct ivar access, use accessor
4   NSLog(@"%@", [[self class] sharedName]);
5}

Category class properties #

Categories must declare class properties as dynamic and provide accessors manually. The compiler enforces this — same rule as instance properties in categories.

 1// Foo+Logging.h
 2@interface Foo (Logging)
 3@property (class, dynamic, assign) BOOL loggingEnabled;
 4@end
 5
 6// Foo+Logging.m
 7@implementation Foo (Logging)
 8static BOOL _loggingEnabled = NO;
 9
10+ (BOOL) loggingEnabled              { return _loggingEnabled; }
11+ (void) setLoggingEnabled:(BOOL) v  { _loggingEnabled = v; }
12@end

The file-static is in Foo+Logging.m — shared across all subclasses of Foo that don't override the accessor. This is the correct pattern for inheritable settings: [Bear setLoggingEnabled:YES] dispatches to Foo(Logging)'s setter (if Bear doesn't override it), setting the same _loggingEnabled that [Animal loggingEnabled] reads.

For per-class settings, the subclass overrides the accessor with its own static:

1// Bear+Logging.m
2@implementation Bear (Logging)
3static BOOL _bearLoggingEnabled = NO;
4
5+ (BOOL) loggingEnabled              { return _bearLoggingEnabled; }
6+ (void) setLoggingEnabled:(BOOL) v  { _bearLoggingEnabled = v; }
7@end

Now [Bear setLoggingEnabled:YES] only affects Bears. Full control, no magic.


Memory Layout #

classpair allocation (contiguous):
┌──────────────────────────────────────────────────────────────┐
│ struct _mulle_objc_objectheader  infraclassheader             │
│ struct _mulle_objc_infraclass    infraclass  ← self in +     │
│ unsigned char                    _padding[...]                │
│ struct _mulle_objc_objectheader  metaclassheader              │
│ struct _mulle_objc_metaclass     metaclass                    │
│ ... (mixins, protocolids, categoryids, lock, etc.)           │
│ uint32_t                         classindex                   │
├──────────────────────────────────────────────────────────────┤
│ CLASS PROPERTY IVARS (appended by runtime at creation)        │
│   [inherited superclass class ivars first, then own]          │
│   NSString *_sharedName;    // offset = BASE + 0             │
│   int       _sharedCount;   // offset = BASE + sizeof(ptr)   │
└──────────────────────────────────────────────────────────────┘

Where:

1#define MULLE_OBJC_CLASSPAIR_IVAR_BASE \
2   (sizeof(struct _mulle_objc_classpair) - \
3    offsetof(struct _mulle_objc_classpair, infraclass))

self in + methods = &classpair->infraclass. Class property ivar at: (char *)self + MULLE_OBJC_CLASSPAIR_IVAR_BASE + field_offset.

This is a compile-time constant from the runtime headers.


Inheritance #

Class property ivars compose through inheritance exactly like instance ivars. The compiler lays out superclass class ivars first, then subclass own ivars. classinstancesize includes the full inherited + own size.

1@interface Animal : NSObject
2@property (class, assign) int population;
3@end
4
5@interface Bear : Animal
6// inherits _population at BASE+0
7@property (class, assign) int bearCount;  // own ivar at BASE+sizeof(int)
8@end

Each class has independent storage — [Animal population] and [Bear population] are different memory locations in different classpairs. The offsets are the same, but self differs.

Subclass redeclaring a superclass class property is an error (same as instance ivars). Overriding the synthesized accessor method is allowed.

Per-class vs shared-hierarchy state #

Synthesized class property storage is per-class. [Bear new] calling Animal's +new will access Bear's _population (because self is Bear's infraclass at runtime).

For shared-hierarchy state (total of all Animals including subclasses), use a @property(class, dynamic) in the base class with a file-static backing — the file-static is compile-time bound to the declaring .m file, so it's always the same variable regardless of which subclass dispatches to it:

 1// Animal.m
 2@interface Animal (Population)
 3@property (class, dynamic, assign) int totalPopulation;
 4@end
 5
 6@implementation Animal (Population)
 7static int _totalPopulation = 0;
 8+ (int)  totalPopulation            { return _totalPopulation; }
 9+ (void) setTotalPopulation:(int) v { _totalPopulation = v; }
10@end
11
12@implementation Animal
13+ (instancetype) new
14{
15   Animal *a = [super new];
16   _totalPopulation++;   // always Animal.m's variable, regardless of receiver
17   return a;
18}
19@end

Loadclass Changes #

Drop +classExtraSize #

The +classExtraSize method is removed. Its purpose (enlarging the classpair) is replaced by a struct field. This is a breaking change — any existing use of +classExtraSize must be migrated.

Drop selfstorage #

No longer needed — there is no external storage to patch.

New field: classinstancesize #

Add to struct _mulle_objc_loadclass (not base — only for classes):

 1struct _mulle_objc_loadclass
 2{
 3   struct _mulle_objc_loadclassbase  base;
 4   ...
 5   int                               instancesize;
 6   int                               classinstancesize;  // NEW: total size incl. inherited
 7   struct _mulle_objc_ivarlist       *instancevariables;
 8   struct _mulle_objc_ivarlist       *classvariables;    // own class property ivars only
 9   mulle_objc_classid_t              *mixinids;
10};

classinstancesize = total size of all class property ivars including inherited ones (same as instancesize includes superclass instance ivars).

The runtime uses classinstancesize as the extrasize parameter when calling mulle_objc_universe_new_classpair. The allocated memory is zeroed.

classvariables ivar list #

The compiler emits ivar descriptors for the class's own class properties in classvariables (not inherited ones — same as instancevariables). Each ivar has:

The runtime registers this ivar list on the metaclass, enabling introspection.


Accessor Synthesis #

For each class property without a user-provided accessor, the compiler synthesizes getter/setter with direct mutex locking (no message send):

 1+ (int) sharedCount
 2{
 3   mulle_objc_infraclass_lock_classproperty(self);
 4   int v = self->_sharedCount;
 5   mulle_objc_infraclass_unlock_classproperty(self);
 6   return v;
 7}
 8
 9+ (void) setSharedCount:(int)value
10{
11   mulle_objc_infraclass_lock_classproperty(self);
12   self->_sharedCount = value;
13   mulle_objc_infraclass_unlock_classproperty(self);
14}

For copy properties:

1+ (void) setSharedName:(NSString *)value
2{
3   mulle_objc_infraclass_lock_classproperty(self);
4   NSString *old = self->_sharedName;
5   self->_sharedName = [value copy];
6   [old autorelease];
7   mulle_objc_infraclass_unlock_classproperty(self);
8}

User-provided accessors suppress synthesis (same rule as instance properties). +lock/+unlock are for user code doing compound operations — synthesized accessors never call them (avoids override surprises and dispatch overhead).


Locking #

Metaclass recursive mutex #

The runtime initializes the metaclass mutex whenever any class property list arrives with n > 0 — either from the main class (classinstancesize > 0) or from a category (classproperties.n > 0). This ensures the mutex is always available when class properties exist, regardless of where they are declared.

Destroyed at class teardown after property clearing.

+lock / +unlock / +tryLock #

Implemented in NSObject as regular class methods — inherited by all classes:

1+ (void) lock    { mulle_objc_infraclass_lock_classproperty(self); }
2+ (void) unlock  { mulle_objc_infraclass_unlock_classproperty(self); }
3+ (int)  tryLock { return mulle_objc_infraclass_trylock_classproperty(self); }

No compiler synthesis needed. Categories can use [self lock] freely because it is always inherited from NSObject.

Compound operations #

1[Foo lock];
2[Foo setSharedName:@"hello"];
3[Foo setSharedCount:42];
4[Foo unlock];

Recursive mutex allows re-entry from synthesized accessors within the lock.


Lifecycle — No Synthesized Methods #

The runtime manages class property ivar lifecycle entirely from metadata.

Creation #

  1. Runtime allocates classpair with extrasize = classinstancesize (zeroed)
  2. Runtime inits metaclass mutex when any class property list has n > 0 (main class or category — whichever arrives first)
  3. Class is ready — +initialize called lazily on first message (user code)

Teardown #

  1. +deinitialize called (user code)
  2. Runtime clears class properties: walks the full class hierarchy property lists (own + inherited superclass), calls setter with nil (or direct ivar release for readonly). Each class's own property list is walked once — the walk traverses superclasses to cover all ivars stored in this classpair.
  3. Runtime destroys metaclass mutex

Property clearing — migrate from MulleObjC #

Existing code to migrate: Source: /home/src/srcO/mulle-objc/MulleObjC/src/function/MulleObjCProperty.m

Functions to move into the runtime (rename to C conventions, drop MulleObjCObjectIsInstance assert or replace with runtime equivalent):

The walk function is used as-is (with superclass traversal) for both instance dealloc and class teardown:

1int  mulle_objc_infraclass_walk_clearable_properties(
2        struct _mulle_objc_infraclass *infra,
3        mulle_objc_walkpropertiescallback_t f,
4        void *userinfo);

Usage:

The superclass walk is correct for both cases: instance dealloc clears inherited instance ivars, class teardown clears inherited class ivars stored in this classpair.

After migration, MulleObjC calls the runtime functions directly — the MulleObjCProperty.m wrappers become thin or disappear.

Why no synthesized lifecycle methods #


Categories #

@property(class) in categories requires dynamic #

Category class properties must be declared dynamic — same rule as instance properties in categories. The compiler does not synthesize storage or accessors. The user provides the implementation.

 1@interface Foo (Extra)
 2@property (class, dynamic, assign) int extraValue;
 3@end
 4
 5@implementation Foo (Extra)
 6static int _extraValue;
 7
 8+ (int) extraValue
 9{
10   mulle_objc_infraclass_lock_classproperty(self);
11   int v = _extraValue;
12   mulle_objc_infraclass_unlock_classproperty(self);
13   return v;
14}
15+ (void) setExtraValue:(int)value
16{
17   mulle_objc_infraclass_lock_classproperty(self);
18   _extraValue = value;
19   mulle_objc_infraclass_unlock_classproperty(self);
20}
21@end

self->_field in + Methods — Implementation #

Approach: Internal struct type for self #

In + methods of a class with class properties, Sema gives self an internal struct pointer type that includes the class property ivars at their correct offsets. This makes self->_field resolve as a normal C MemberExpr.

The struct #

For a class Foo (inheriting from NSObject with no class properties) with class property int sharedCount, the compiler internally creates:

1struct __Foo_classivars {
2   char __spacer[MULLE_OBJC_CLASSPAIR_IVAR_BASE];  // absorbs infraclass + classpair tail
3   // inherited superclass class ivars here (if any)
4   int  _sharedCount;                               // own ivar
5};

For a subclass Bar : Foo adding NSString *name:

1struct __Bar_classivars {
2   char       __spacer[MULLE_OBJC_CLASSPAIR_IVAR_BASE];
3   int        _sharedCount;   // inherited from Foo, same offset
4   NSString  *_name;          // own
5};

Sema #

In ActOnStartOfObjCMethodDef, when the method is a + method and the class has class properties (own or inherited):

  1. Build RecordDecl with a char[] spacer + inherited class ivars + own class ivars
  2. Create pointer type: struct __Foo_classivars *
  3. Set self's type to this pointer type internally

Then self->_sharedCount resolves naturally via MemberExpr on the struct.

CodeGen #

No special handling needed — MemberExpr on a struct pointer emits a GEP at the field's offset. The spacer ensures the offset is correct:

1; self->_sharedCount
2%ptr = getelementptr %struct.__Foo_classivars, ptr %self, i32 0, i32 1
3%val = load i32, ptr %ptr

Messaging still works #

self is typed as a struct pointer internally, but ObjC message sends on struct pointers already work in mulle-objc (the metaABI casts to void * for mulle_objc_object_call). So [self sharedCount] still dispatches correctly — the struct type doesn't interfere with messaging.

Instance methods #

In - methods, self keeps its normal type (Foo *). Class property ivars are not accessible via self->_field in instance methods — use [[self class] sharedCount] instead.


What This Removes (vs current v1 implementation) #


What This Keeps #


Runtime Changes Required #

  1. Add classinstancesize field to struct _mulle_objc_loadclass
  2. Use classinstancesize as extrasize in mulle_objc_universe_new_classpair
  3. Init metaclass mutex when any class property list has n > 0 (main class or category)
  4. At teardown: walk own classproperties list, call setters with nil (migrate from MulleObjCProperty.m — see above)
  5. Remove selfstorage from _mulle_objc_loadclassbase
  6. Remove isa-patching code from mulle-objc-load.c
  7. Remove +classExtraSize call from classpair creation
  8. Define MULLE_OBJC_CLASSPAIR_IVAR_BASE macro in public header

Compiler Changes Required #

  1. Remove Self VarDecl creation from SemaDeclObjC.cpp
  2. Remove [Self msg] handler from SemaExprObjC.cpp
  3. Remove EmitClassPropertyStorage from CGObjCMulleRuntime.cpp
  4. Compute class ivar layout including inherited ivars (same as instance ivar layout)
  5. Emit classinstancesize (total incl. inherited) in loadclass struct
  6. Emit classvariables ivar list (own ivars only, absolute offsets) in loadclass struct
  7. In ActOnStartOfObjCMethodDef: build internal __Foo_classivars struct for self in + methods (spacer + inherited + own class ivars)
  8. Synthesize accessors using self->_field (resolved via internal struct type) with direct mulle_objc_infraclass_lock/unlock_classproperty calls
  9. No +lock/+unlock/+tryLock synthesis — provided by NSObject
  10. Enforce dynamic for class properties in categories (already done for instance properties)
  11. Update rewriter
  12. Bump load version to v22
last updated: