Skip to content

JNI: closures returned to Java, and optional closure parameters - #21

Open
piercifani wants to merge 3 commits into
jni/16-closure-struct-argumentsfrom
jni/17-closure-results
Open

piercifani wants to merge 3 commits into
jni/16-closure-struct-argumentsfrom
jni/17-closure-results

Conversation

@piercifani

@piercifani piercifani commented Nov 3, 2026 •

Copy link
Copy Markdown

Stack 17/21 · base: jni/16-closure-struct-arguments · previous: #20 · next: #22 (jni/18-async-closures)

Problem

jextract skipped closures going from Swift to Java:

  • func makeFormatter(prefix: String) -> (Item) -> String had no Java method.
  • public var onItem: ((Item) -> Void)? had neither getter nor setter. Optional closure parameters weren't supported, and the parentheses in ((Item) -> Void)? made it a one-element tuple.

Changes

Returned closures. The native method returns the handle of a Swift box holding the closure. The Java method wraps the handle in a SwiftClosure and returns a lambda of the closure's functional interface:

public Notifier.makeFormatter.result makeFormatter(String prefix) {
  SwiftClosure closure$ = new SwiftClosure(Notifier.$makeFormatter(prefix, this.$memoryAddress()));
  return (Notifier.makeFormatter.result) (Item closure$0) -> (String) closure$.call(closure$0);
}
  • Interface: a returned closure gets its own functional interface, <Type>.<method>.result, in the method's helper class.
  • Calls: SwiftClosure.call(Object...) passes boxed arguments to the box. The box converts them, and the closure's result, with the JobjectBridges that arrays and dictionaries use. So the closure's types can be anything an array element can be: primitives, strings, jextracted types, optionals and collections.
  • Lifetime: SwiftClosure (new in SwiftKitCore) releases the Swift closure when it becomes unreachable. It uses a cleaner thread, like the automatic arena, so it works on Android.
  • Not supported: async, throwing and @MainActor closures stay skipped. Java calls the closure synchronously, on any thread.

Optional closure parameters. They become Optional<Interface>, or the nullable interface with optionalsAsNullable. In Swift they lower to nil or the escaping closure.

Closure properties. A getter returns its setter's interface, <Type>.set<Property>.newValue, so Kotlin sees a single property. A read-only property's getter gets its own <Type>.get<Property>.result.

Supporting changes:

  • Parentheses: a parenthesized type (T) is now parsed as T, as Swift does, instead of a one-element tuple.
  • wrap-java: its …Optional convenience overloads call self.method(…). jextract's helper class setOnItem became a Swift nested type of the same name, which made the bare call setOnItem(…) ambiguous.

Kotlin in this PR

ClosuresTest.kt with Closures.swift:

// public var onItem: ((Item) -> Void)?
notifier.onItem = { item -> seen += item.id }   // Kotlin converts the lambda in the assignment
notifier.onItem?.apply(Item.init("b", 1))       // the Swift closure, calling the lambda
notifier.onItem = null

// public func makeFormatter(prefix: String) -> (Item) -> String
val format = Notifier.init().makeFormatter("#")
format.apply(Item.init("1", 1))                 // "#1"

Why Skip needs it

Skip bridges closure properties and closure results as Kotlin function types, which Kotlin code assigns lambdas to and calls. A production Skip Fuse app (16 Skip modules) has about 420 closure properties and closure results.

Testing

  • Kotlin sample: ./gradlew :Samples:SwiftJavaExtractJNIKotlinSampleApp:test passes (45 tests).
  • New JNIClosureResultsTests (7 tests):
    • the Java lambda, native method and result interface;
    • the Swift box and its bridges;
    • a settable property's getter returning the setter's interface, with Optional and with optionalsAsNullable, and its helper class printed once;
    • a read-only property's own interface;
    • nil or the closure for an optional closure parameter;
    • a @MainActor closure result being skipped.
  • JNI sample: ClosureResultsTest calls a returned (Int64) -> Int64 1,000 times and a returned struct closure. It sets, reads, calls and clears an optional closure property. It drops 100 returned closures and waits for their Swift captures to be released.
  • Full swift test --disable-experimental-prebuilts --disable-sandbox (682 tests), ./gradlew :Samples:SwiftJavaExtractJNISampleApp:test (298 tests) and ./gradlew :SwiftKitCore:test pass.
  • swift format lint --strict on changed files; git diff --check.

Not in this PR

🤖 Generated with Claude Code

piercifani and others added 2 commits November 3, 2026 20:33
A function returning a closure, or a property of closure type, was
skipped. The native method now returns the handle of a Swift closure
box, and the Java method wraps it in a `SwiftClosure` (new in
SwiftKitCore, released by a cleaner when unreachable) and returns a
lambda of the closure's functional interface, `<Type>.<method>.result`.
The lambda calls `SwiftClosure.call` with boxed arguments; the box
converts them, and the result, with the `JobjectBridge`s collections
already use. Async, throwing and `@MainActor` closures stay skipped:
Java calls the closure synchronously, on any thread.

An optional closure parameter is `Optional<Interface>` (or the nullable
interface with `optionalsAsNullable`) and lowers to `nil` or the
escaping closure. A closure property's getter returns its setter's
interface, `<Type>.set<Property>.newValue`, so Kotlin sees one property
and can assign it a lambda.

Supporting changes:
- `(T)` is parsed as `T`, not a one-element tuple, so
  `((Item) -> Void)?` is an optional closure.
- wrap-java's `…Optional` convenience overloads call `self.method(…)`:
  jextract's helper class of the same name made the bare call
  ambiguous.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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