Skip to content

JNI: Swift class hierarchies as Java class hierarchies - #8

Open
piercifani wants to merge 2 commits into
jni/06-java-implemented-protocolsfrom
jni/07-class-inheritance
Open

piercifani wants to merge 2 commits into
jni/06-java-implemented-protocolsfrom
jni/07-class-inheritance

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 7/21 · base: jni/06-java-implemented-protocols · previous: #7 · next: #10

Problem

In JNI mode every Swift class becomes an unrelated public final class … implements JNISwiftInstance. For

open class Animal { open func sound() -> String; public func describe() -> String }
public final class Dog: Animal { public func fetch() -> String }
public func makeDog() -> Animal

Dog doesn't extend Animal. So Animal a = dog doesn't compile, a Dog can't be passed where an Animal is expected, Dog doesn't get describe(), and makeDog() returns a plain Animal wrapper that can't be cast to Dog.

Changes

  • extends: a class whose superclass is extracted in the same module extends the superclass's Java class and no longer re-declares implements JNISwiftInstance. Only concrete classes are affected; generic classes, specializations and error types are unchanged.
  • Extendable classes: an open class, or one with extracted subclasses, is no longer final and gets a protected designated constructor. Final classes with no hierarchy are printed exactly as before.
  • One owner of the pointer: a subclass's constructor calls super(selfPointer, swiftArena). The pointer, $memoryAddress() and the cleanup live in the root class. The cleanup's destroy function already uses the virtual $typeMetadataAddress(), so each instance is destroyed with its own class's metadata.
  • Inherited members: these work without new thunks. A class instance is a pointer to one reference, so the superclass's thunks read a subclass instance through the same layout.
  • Dynamic type on return: a class with extracted subclasses gets a $dynamicTypeMetadataAddress(selfPointer) thunk (type(of:) on the instance). Its wrapMemoryAddressUnsafe compares the result against each subclass's $typeMetadataAddressDowncall(), deepest subclass first, and wraps in the most specific Java class. Every call site that wraps a returned instance gets this without other changes. A subclass's metadata accessor becomes package-private so its base classes can call it.

Why Skip needs it

Skip Fuse view models share one @MainActor @Observable open class base. In a production Skip Fuse app (16 Skip modules), 189 view models subclass it, and Compose code passes them around as the base type.

Kotlin in this PR

  • ClassHierarchyTest.kt with ClassHierarchy.swift: a Dog used as an Animal, passed where an Animal is expected, and returned as Animal but received as a Dog.
  • ViewModelTest.kt with ViewModels.swift: a view model as Compose code uses it, built on every PR below this one. A @MainActor @Observable open base class; a subclass with a Callbacks struct of @MainActor @Sendable closures taking structs; an input enum with payloads; and an async main-actor load(), awaited by pumping the main run loop (on Android the main looper does this). macOS only.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes at this point in the stack.
  • New JNIClassInheritanceTests (6 tests). The 5 for the change fail on the base branch; the control (a plain final class prints as before) passes on both.
  • JNI sample: Shape / Square with Java tests for subclass-as-superclass, inherited and overridden members, an array of the base type, and a Shape return that is a Square in Java.
  • Standalone Swift 6 packages pass Kotlin tests on the JVM. One covers Animal/Dog: subclass as base, subclass passed as base, and a base return keeping its dynamic type. The other is the full Skip view-model shape: a @MainActor @Observable open base, a subclass with a Callbacks struct of @MainActor @Sendable closures, an input enum with payloads, and an async main-actor load(). The last one builds on the earlier PRs in this stack.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox and ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test pass.
  • swift format lint --strict on changed files; git diff --check.

Not in this PR

  • Superclasses from another module (the subclass stays a standalone class, as before).
  • Name clashes the Java language forbids across a hierarchy, such as a static method in a subclass with the signature of an instance method in its superclass.
  • Wrapping a returned instance costs one extra JNI call when its static type has extracted subclasses.

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 17:35
Every Swift class became an unrelated final Java class, so a subclass couldn't
be used as its superclass, didn't expose inherited members, and an instance
returned as its superclass couldn't be cast to its own class.

- A class whose superclass is extracted in the same module extends the
  superclass's Java class. `open` classes and classes with extracted subclasses
  are no longer final and get a protected designated constructor; subclasses
  call `super(...)`, so the pointer and cleanup live in the root class.
- A class with extracted subclasses gets a `$dynamicTypeMetadataAddress` thunk,
  and its `wrapMemoryAddressUnsafe` wraps an instance in the Java class of its
  dynamic type, deepest subclass first.
- Inherited members need no new thunks: a class instance is a pointer to one
  reference, which the superclass's thunks read through the same layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ose uses it

Animal/Dog for subclass-as-superclass and dynamic-class returns; and the full
view-model shape: a main-actor observable open base class, a subclass with
callbacks taking structs, an input enum with payloads, and async loading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@piercifani
piercifani force-pushed the jni/07-class-inheritance branch from 2de65e8 to 002d8af Compare November 3, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant