Skip to content

Commit 806087a

Browse files
maleadtclaude
andauthored
Rework type model: concrete wrappers + parallel Kind-lattice dispatch (#63)
Today, `@objcwrapper Foo <: Bar` emits an abstract `Foo` plus a concrete `FooInstance`. Anything stored as a `Foo` (containers, struct fields, inferred return types) carries an abstract type, which boxes in `Vector{Foo}`, breaks inference through property chains, and triggers dynamic dispatch in profiles. With this change, each `@objcwrapper Foo <: Bar` now emits *two* parallel definitions: ```julia struct Foo <: Object # concrete leaf (storage) ptr::id{Foo} end abstract type FooKind <: BarKind end # parallel lattice (dispatch) classkind(::Type{Foo}) = FooKind ``` Crucially, the inheritance stays flat: every wrapper is `<: Object` directly so that `Vector{NSString}` is bits-packed and inference sees fixed types through property access. This of course breaks dispatch on "non-leaf" classes, for which we introduce a parallel Kind lattice used by `@objcmethod` methods: ```julia @objcmethod foo(obj::KindOf{Bar}) = ... ``` The macro `KindOf{T}` lowers to trait dispatch on `Type{<:classkind(T)}` plus a method that forwards from untyped `Object` inputs. Multiple sites on the same function compose naturally -- Julia's dispatch picks the most specific Kind at the call site -- and downstream wrappers automatically participate by virtue of `SubKind <: ParentKind`. When the static type is known at the call site (the common case), the entry-then-body chain folds at compile time, so the trait dispatch is often zero-cost. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a006f18 commit 806087a

7 files changed

Lines changed: 628 additions & 103 deletions

File tree

Project.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
name = "ObjectiveC"
22
uuid = "e86c9b32-1129-44ac-8ea0-90d5bb39ded9"
3-
version = "3.4.2"
3+
version = "4.0.0"
44

55
[deps]
66
CEnum = "fa961155-64e5-5f13-b03f-caf6b980ea82"
7+
ExprTools = "e2ba6199-217a-4e67-a87a-7c52f15ade04"
78
Libdl = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
89
Preferences = "21216c6a-2e73-6563-6e65-726566657250"
910

1011
[compat]
1112
CEnum = "0.5"
13+
ExprTools = "0.1"
1214
Preferences = "1"
1315
julia = "1.10"
1416

README.md

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,29 +41,93 @@ julia> obj_ptr = @objc [NSValue valueWithPointer:C_NULL::Ptr{Cvoid}]::id{NSValue
4141
id{NSValue}(0x00006000023cfca0)
4242

4343
julia> obj = NSValue(obj_ptr)
44-
NSValueInstance (object of type NSConcreteValue)
44+
NSValue (object of type NSConcreteValue)
4545
```
4646

47-
The generated `NSValue` class is an abstract type that implements the type hierarchy, while
48-
the `NSValueInstance` object is a concrete structure that houses the `id` pointer. This
49-
split makes it possible to implement multi-level inheritance and attach functionality at
50-
each level of the hierarchy, and should be entirely transparent to the user (i.e., you
51-
should never need to use the `*Instance` types in code or signatures).
47+
`@objcwrapper` emits a concrete `struct NSValue <: Object` holding the `id` pointer. The
48+
macro also generates conversion routines so the wrapper can be passed directly to `@objc`
49+
calls expecting `id`.
5250

53-
The `@objcwrapper` macro also generates conversion routines and accessors that makes it
54-
possible to use these objects directly with `@objc` calls that require `id` pointers:
51+
To declare a method on a wrapper class, use `@objcmethod` with a `KindOf{T}` argument
52+
marker:
5553

5654
```julia
57-
julia> get_pointer(val::NSValue) = @objc [val::id{NSValue} pointerValue]::Ptr{Cvoid}
55+
julia> @objcmethod get_pointer(val::KindOf{NSValue}) =
56+
@objc [val::id{NSValue} pointerValue]::Ptr{Cvoid}
5857

5958
julia> get_pointer(obj)
6059
Ptr{Nothing} @0x0000000000000000
6160
```
6261

62+
`KindOf{T}` in combination with `@objcmethod` makes the method polymorphic over `T` and any
63+
wrapped subclasses; important because wrappers do *not* form a Julia `<:` chain (every
64+
wrapper is `<: Object` directly), so a plain `f(val::NSValue, …)` would silently fail to
65+
dispatch on a subclass.
66+
67+
68+
## Type model
69+
70+
Every `@objcwrapper Foo <: Bar` declaration produces **two** parallel definitions:
71+
72+
* a concrete `struct Foo <: Object` holding `ptr::id{Foo}`, for storage and value identity;
73+
* an abstract `FooKind <: BarKind` (defaulting to `<: ObjectKind`), for dispatch purposes.
74+
75+
The concrete struct chain stays flat: every wrapper is `<: Object` directly, regardless of
76+
its ObjC parent, so that `Vector{NSString}` is alloc-free and inference sees a single fixed
77+
type through container access. The Kind lattice mirrors the ObjC class hierarchy and is
78+
walked by Julia's native multiple dispatch for polymorphic methods.
79+
80+
### Methods on wrapper classes
81+
82+
**Recommendation: use `@objcmethod` with `KindOf{T}` for wrapper-typed parameters.** Because
83+
the concrete struct chain is flat, a plain `f(x::Parent, …)` is monomorphic and does *not*
84+
match a later `@objcwrapper Sub <: Parent`. To avoid this footgun, route every wrapper-typed
85+
argument through `@objcmethod` and mark it with `KindOf{T}`:
86+
87+
```julia
88+
@objcmethod release(obj::KindOf{NSObject}) =
89+
@objc [obj::id{NSObject} release]::Cvoid
90+
91+
@objcmethod endEncoding!(ce::KindOf{MTLCommandEncoder}) =
92+
@objc [ce::id{MTLCommandEncoder} endEncoding]::Nothing
93+
94+
@objcmethod function Base.copy(kernel::KindOf{MPSKernel})
95+
K = typeof(kernel)
96+
obj = @objc [kernel::id{MPSKernel} copy]::id{MPSKernel}
97+
K(reinterpret(id{K}, obj))
98+
end
99+
```
100+
101+
For each `KindOf{T}` slot, `@objcmethod` emits a body method dispatched on the parallel
102+
Kind lattice and an entry forwarder on `::Object` that routes calls to it. When the
103+
argument's static type is known at the call site (the common case), the chain folds at
104+
compile time to a direct call; the trait dispatch is zero-cost.
105+
106+
`KindOf{T}` is **macro-level syntax**, not a real Julia type. `@objcmethod` rewrites every
107+
`KindOf{T}` slot at macroexpand-time and the marker never reaches runtime, so it cannot
108+
appear in containers, fields, or return positions — `Vector{KindOf{T}}`, `id{KindOf{T}}`,
109+
and `f(…)::KindOf{T}` are all meaningless. The storage axis is unaffected by the choice
110+
of dispatch style: `Vector{NSString}` is still tightly packed, and the wrapper struct
111+
remains an immutable `isbits` value carrying a single `id` pointer regardless of whether
112+
methods on it use `@objcmethod`.
113+
114+
**When to write a plain method instead.** Skipping `@objcmethod` is fine when:
115+
116+
* The class is a true leaf with no `@objcwrapper` subclasses (e.g. `NSString`, where the
117+
underlying `__NSCFString` / `NSTaggedPointerString` subclasses are *not* wrapped on the
118+
Julia side and so `typeof(obj)` is always `NSString`). The ObjC runtime still
119+
dispatches to the real concrete class via `objc_msgSend`, so a normal
120+
`Base.length(s::NSString) = Int(s.length)` works correctly.
121+
* You genuinely want to refuse subclasses (rare in Objective-C, where messaging is
122+
polymorphic by design).
123+
124+
In all other cases, prefer `@objcmethod`: it costs only a runtime type check while
125+
protecting the method from breaking when a downstream `@objcwrapper Sub <: Parent` lands.
126+
63127

64128
## Properties
65129

66-
A common pattern in Objective-C is to use properties to acces instance variables. Although
130+
A common pattern in Objective-C is to use properties to access instance variables. Although
67131
it is possible to access these directly using `@objc`, ObjectiveC.jl provides a macro to
68132
automatically generate the appropriate `getproperty`, `setproperty!` and `propertynames`
69133
definitions:

src/ObjectiveC.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ module ObjectiveC
22

33
using CEnum
44

5+
using ExprTools: splitdef, combinedef
6+
57
using Preferences
68

79
"""

src/foundation.jl

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,37 @@ export NSObject, retain, release, autorelease, is_kind_of
3333
@autoproperty retainCount::NSUInteger
3434
end
3535

36-
function Base.show(io::IO, ::MIME"text/plain", obj::NSObject)
36+
# Methods defined by ObjC on NSObject. `@objcmethod` dispatches through
37+
# the Kind lattice, so downstream wrappers (Metal, MPS, user code) automatically
38+
# participate without re-declaration: `classkind(typeof(obj)) <: NSObjectKind`
39+
# routes through the trait-dispatched body, while a non-NSObject `Object`
40+
# subtype hits a clean `MethodError` on the body method.
41+
@objcmethod function Base.show(io::IO, ::MIME"text/plain",
42+
obj::KindOf{NSObject})
3743
if get(io, :compact, false)
3844
print(io, String(obj.description))
3945
else
4046
print(io, String(obj.debugDescription))
4147
end
4248
end
4349

44-
release(obj::NSObject) = @objc [obj::id{NSObject} release]::Cvoid
50+
@objcmethod release(obj::KindOf{NSObject}) =
51+
@objc [obj::id{NSObject} release]::Cvoid
4552

46-
autorelease(obj::NSObject) = @objc [obj::id{NSObject} autorelease]::Cvoid
53+
@objcmethod autorelease(obj::KindOf{NSObject}) =
54+
@objc [obj::id{NSObject} autorelease]::Cvoid
4755

48-
retain(obj::NSObject) = @objc [obj::id{NSObject} retain]::Cvoid
56+
@objcmethod retain(obj::KindOf{NSObject}) =
57+
@objc [obj::id{NSObject} retain]::Cvoid
4958

50-
ObjectiveC.class(obj::NSObject) = @objc [obj::id{NSObject} class]::Class
51-
52-
function is_kind_of(obj::NSObject, class::Class)
59+
@objcmethod function is_kind_of(obj::KindOf{NSObject}, class::Class)
5360
@objc [obj::id{NSObject} isKindOfClass:class::Class]::Bool
5461
end
5562

56-
function Base.:(==)(obj1::NSObject, obj2::NSObject)
63+
# Default equality for ObjC objects via `isEqual:`. Specific classes can
64+
# override this for a faster path (NSString, NSURL, etc. already do).
65+
@objcmethod function Base.:(==)(obj1::KindOf{NSObject},
66+
obj2::KindOf{NSObject})
5767
@objc [obj1::id{NSObject} isEqual:obj2::id{NSObject}]::Bool
5868
end
5969

@@ -299,7 +309,7 @@ end
299309

300310
NSArray() = NSArray(@objc [NSArray array]::id{NSArray})
301311

302-
function NSArray(elements::Vector{<:NSObject})
312+
function NSArray(elements::Vector{<:Object})
303313
arr = @objc [NSArray arrayWithObjects:elements::id{Object}
304314
count:length(elements)::NSUInteger]::id{NSArray}
305315
return NSArray(arr)
@@ -335,7 +345,7 @@ end
335345

336346
NSDictionary() = NSDictionary(@objc [NSDictionary dictionary]::id{NSDictionary})
337347

338-
function NSDictionary(items::Dict{<:NSObject,<:NSObject})
348+
function NSDictionary(items::Dict{<:Object,<:Object})
339349
nskeys = NSArray(collect(keys(items)))
340350
nsvals = NSArray(collect(values(items)))
341351
dict = @objc [NSDictionary dictionaryWithObjects:nsvals::id{NSArray}
@@ -349,7 +359,7 @@ Base.isempty(dict::NSDictionary) = length(dict) == 0
349359
Base.keys(dict::NSDictionary) = dict.allKeys
350360
Base.values(dict::NSDictionary) = dict.allValues
351361

352-
function Base.getindex(dict::NSDictionary, key::NSObject)
362+
function Base.getindex(dict::NSDictionary, key::Object)
353363
ptr = @objc [dict::id{NSDictionary} objectForKey:key::id{NSObject}]::id{Object}
354364
ptr == nil && throw(KeyError(key))
355365
return ptr

src/primitives.jl

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,19 @@ Base.:(==)(x::Ptr, y::id) = throw(ArgumentError("Cannot compare id with Ptr"))
132132
function Base.convert(::Type{id{T}}, x::id{U}) where {T,U}
133133
# nil is an exception (we want to be able to use `nil` in `@objc` directly)
134134
x == nil && return Base.bitcast(id{T}, nil)
135-
# otherwise, types must match (i.e., only allow converting to a supertype)
136-
U <: T || throw(ArgumentError("Cannot convert id{$U} to id{$T}"))
135+
# allow conversion to a Julia supertype, or up an Objective-C inheritance chain (encoded
136+
# by `inherits_from`). When both U and T are concrete leaf classes, Julia's `<:` alone
137+
# is insufficient, since every `@objcwrapper` class is <:Object but not <:its-ObjC-parent.
138+
if !(U <: T) && !(compatible_id_types(T, U)::Bool)
139+
throw(ArgumentError("Cannot convert id{$U} to id{$T}"))
140+
end
137141
Base.bitcast(id{T}, x)
138142
end
139143

144+
# default: only Julia subtyping. syntax.jl extends this to use `inherits_from`
145+
# for the Object hierarchy.
146+
compatible_id_types(::Type, ::Type) = false
147+
140148
# conversion to integer
141149
Base.Int(x::id) = Base.bitcast(Int, x)
142150
Base.UInt(x::id) = Base.bitcast(UInt, x)
@@ -153,12 +161,12 @@ Base.unsafe_convert(::Type{P}, x::id) where {P<:id} = convert(P, x)
153161

154162
# Objects
155163

156-
# Object is an abstract type, so that we can define subtypes with constructors.
157-
# The expected interface is that any subtype of Object should be convertible to id.
158-
164+
# Object is the sole abstract supertype of every Objective-C wrapper. Each
165+
# `@objcwrapper` declaration emits a concrete `struct`/`mutable struct` that
166+
# inherits directly from `Object`. The Objective-C class hierarchy is encoded
167+
# via a recursive `inherits_from` trait (see syntax.jl) rather than via
168+
# Julia's `<:` relation.
159169
abstract type Object end
160-
# interface: subtypes of Object should be convertible to `id`s
161-
# (i.e., `Base.unsafe_convert(::Type{id}, ::MyObj)`)
162170

163171
const nil = id{Object}(0)
164172

@@ -171,10 +179,3 @@ class(obj::Union{Object,id}) =
171179
Base.methods(obj::Union{Object,id}) = methods(class(obj))
172180

173181
Base.show(io::IO, obj::T) where {T <: Object} = print(io, "$T (object of type ", class(obj), ")")
174-
175-
struct UnknownObject <: Object
176-
ptr::id
177-
UnknownObject(ptr::id) = new(ptr)
178-
end
179-
Base.unsafe_convert(T::Type{<:id}, obj::UnknownObject) = convert(T, obj.ptr)
180-
Object(ptr::id) = UnknownObject(ptr)

0 commit comments

Comments
 (0)