Skip to content

Rework type model: concrete wrappers + parallel Kind-lattice dispatch#63

Merged
maleadt merged 18 commits into
masterfrom
tb/rework
May 12, 2026
Merged

Rework type model: concrete wrappers + parallel Kind-lattice dispatch#63
maleadt merged 18 commits into
masterfrom
tb/rework

Conversation

@maleadt
Copy link
Copy Markdown
Member

@maleadt maleadt commented May 11, 2026

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 (cf. vchuravy's profile).

The reason we do this is because ObjC's class hierarchy is concrete inheritance with state at every level. For example, NSMutableString is a concrete class with fields and methods, and NSString is also a concrete class with fields and methods that NSMutableString extends. Julia's type system forbids this: only abstract types can have subtypes, only leaves can be concrete. There is no Julia equivalent to "a concrete struct that inherits from another concrete struct."

With this PR, each @objcwrapper Foo <: Bar now emits two parallel definitions:

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:

@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.

Closes #13

maleadt and others added 17 commits May 11, 2026 17:26
Resolves the boxing/inference cost of the old abstract-type-with-Instance-suffix
split (issue #13) by emitting `@objcwrapper`'d classes as concrete structs that
all subtype the sole abstract `Object`. The ObjC class hierarchy is recorded
separately as a runtime trait via `objc_parent` / `inherits_from`, walked at
compile time so the const-folded dispatch costs nothing.

For methods polymorphic over a parent class and its wrapped subclasses, a new
`@objcdispatch f(::KindOf{T}, …)` macro emits the canonical body on a `KindOf{T}`
covariant wrapper (modeled on ObjC's `__kindof T *`) plus a `(::Object, …)`
forwarder that routes via `inherits_from`. `KindOf{T}` is itself a bitstype
wrapper of `id{T}`, so the indirection compiles to a register move.

See the README's new "Type model" section for the three method-writing patterns
and when to reach for each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`KindOf{T}` is no longer a wrapper struct — it's a marker that `@objcdispatch`
substitutes at macro-expansion time with `Union{T, descendants(T)...}`, where
descendants are enumerated by walking the `objc_parent` method table. The
result is a regular Julia method on a concrete Union, dispatched natively, with
no forwarder, no `inherits_from` runtime check, and no `(::Object, ...)` method.

Side effects:

  - `typeof(arg)` inside an `@objcdispatch` body is the concrete subclass. The
    previous "manual `::Object` + `inherits_from` guard" pattern (needed when
    the body had to recover the caller's type) folds back into `@objcdispatch`.
    `Base.copy(::MPSKernel)` and `Base.close(::MTLCommandEncoder)` in the Metal
    port are now plain `@objcdispatch`'s.

  - Two `@objcdispatch` methods on different parents no longer fight over a
    shared `(::Object, ...)` forwarder. Their Unions are disjoint, so they
    co-exist as distinct methods. The manual ndarray.jl branching for
    Unary 2-arg vs Binary 2-arg `encode!` is unnecessary.

Contract: `@objcwrapper Sub <: Parent` must be declared before any
`@objcdispatch` on `KindOf{Parent}` or an ancestor — otherwise the Union is
frozen without `Sub`. `@objcwrapper` walks the `objcdispatch_sites` registry
and emits a warning pointing at affected call sites when the contract is
violated, instructing the user to redeclare the method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously only the first KindOf{T} argument was rewritten; any further
slots stayed as the literal empty marker `KindOf{T}` struct, producing a
method that could never be called. Substitute every slot independently
and register a call site under every distinct parent type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Base.propertynames` was only emitted by `@objcproperties`, so a wrapper
declared without one — common for leaf classes that add no Julia-side
properties of their own — fell back to `fieldnames` and returned `(:ptr,)`.
The runtime accessor chain handled inherited properties correctly via
`objc_getproperty`, but `propertynames` did not surface them.

Emit `Base.propertynames` from `@objcwrapper` itself, and make the
default `objc_propertynames(::Type{<:Object})` walk `objc_parent` instead
of returning an empty list. The per-class method emitted by
`@objcproperties` continues to override this fallback when present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Redeclaring `@objcdispatch` after a late `@objcwrapper` emits a new
method with a wider Union but the narrower method stays in the table —
the signatures genuinely differ, so Julia keeps both. The wider method
only wins for the new subclass; the original is still selected for every
other type. Recommend only the safe option (move the wrapper above the
methods).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`@objcwrapper Foo` (no explicit parent) emitted unqualified `Object` in
the `objc_parent` method and `warn_late_subclass` call, so a caller
that imported only the macro — but not `Object` — got a `UndefVarError`
at expansion time. The struct's `<: Object` declaration was already
qualified; this brings the rest of the emitted code in line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When `T` is abstract (e.g. `Object` itself), every wrapped class is
already `<: T` in Julia's type system, so `Union{T, descendants...}`
simplifies to `T`. The result is that `@objcdispatch f(x::KindOf{Object})`
behaves identically to `f(x::Object)`. Recognise this up front:

- skip the `objc_subtree` method-table walk (the Union would collapse
  anyway),
- skip registering the call site, so subsequent `@objcwrapper`s don't
  warn that the (non-existent) Union is stale.

`::Object` remains the idiomatic way to dispatch over any wrapper; this
just makes `KindOf{Object}` an acceptable, equivalent spelling rather
than one that drags a warning along every new subclass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without `@inline`, the per-class `objc_getproperty`/`objc_setproperty!`
methods emitted by `@objcproperties` block Julia's interprocedural
constant propagation: a literal `obj.length` did not flow the `:length`
symbol through the forwarder chain, so the `if field === :length`
cascade could not be folded and inference fell back to the Union of
every property type in the ancestor chain.

This was a regression vs. the pre-rework `Base.getproperty(::T, ::Symbol)`
that emitted the cascade directly on `Base.getproperty` and benefited
from const-prop without annotation. The rework moved the cascade behind
an extra forwarder (the `objc_getproperty(T, obj, field)` indirection
that walks the trait-based parent chain), and the missing `@inline`
defeated the propagation Julia would otherwise have done.

Found via the profile in #13 (cross-ref to
JuliaGPU/Metal.jl#621), which showed the wide
`Union{NSStringInstance, MTLBufferInstance, ...}` body type on
`unsafe_convert`/`getproperty` call sites along the kernel-launch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closed-world `@objcdispatch f(::KindOf{T})` freezes the dispatch Union
at macro-expansion time, so a subclass `@objcwrapper`'d later (e.g. in
a downstream package) doesn't flow through. That's the right model for
methods owned by a single module, but the wrong one for foundation
primitives like `retain`/`release`/`==`/`is_kind_of` that should remain
available to every wrapper declared anywhere.

`@objcdispatch open=true f(::KindOf{T})` substitutes `KindOf{T}` with
the abstract apex `Object` and prepends a runtime guard
`inherits_from(typeof(arg), T) || throw(MethodError(f, args))` to the
method body. The dispatch matches any wrapper, the guard rejects
non-`T` arguments with a clear `MethodError`, and the call site is
not registered — subsequent `@objcwrapper`s never trigger a
late-subclass warning for it. `inherits_from` is `@inline`d, so for
the typical call site where the concrete arg type is static, the
guard folds at compile time and the open variant compiles to the same
code as the closed one.

Convert the foundation NSObject methods (`release`, `autorelease`,
`retain`, `is_kind_of`, `==`, `show(::MIME"text/plain", …)`) over to
`open=true`. They previously took `::Object` directly and leaned on
`convert(id{NSObject}, …)` failing for non-NSObject arguments — the
new spelling documents the intent and surfaces a `MethodError` against
the function instead of an `ArgumentError` against the id conversion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@maleadt maleadt marked this pull request as ready for review May 12, 2026 06:56
@maleadt
Copy link
Copy Markdown
Member Author

maleadt commented May 12, 2026

JuliaGPU/Metal.jl#776 seems to be working fine, so let's try this out.

@maleadt maleadt merged commit 806087a into master May 12, 2026
12 checks passed
@maleadt maleadt deleted the tb/rework branch May 12, 2026 07:05
@christiangnrd
Copy link
Copy Markdown
Contributor

christiangnrd commented May 12, 2026

Small nit but but the “@objcmethod no longer…” phrasing in the error message seems strange since this is the first release where the macro exists

@maleadt
Copy link
Copy Markdown
Member Author

maleadt commented May 13, 2026

Yeah that's LLMs writing annoying comments based on interim state... Thanks for reading over.
In any case, @objcmethod is gone after #67.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reconsider type model

2 participants