Skip to content

Commit 5dfb732

Browse files
authored
Merge pull request #13 from kdroidFilter/feat/lambda-return-type
feat: Support lambda/function as return type
2 parents 47cb4d9 + 45ecf33 commit 5dfb732

5 files changed

Lines changed: 351 additions & 10 deletions

File tree

README.md

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ No JNI. No annotations. No boilerplate. Just write Kotlin/Native and use it from
121121

122122
## What's supported
123123

124-
### Types — test coverage (960+ end-to-end FFM tests)
124+
### Types — test coverage (970+ end-to-end FFM tests)
125125

126-
Every test compiles Kotlin/Native → `libcalculator.so` (200+ exported symbols) → loads via FFM `MethodHandle` → verifies on JVM. Zero mocks — all 960+ tests cross the real native boundary. Includes 25 load tests (500K+ FFM calls), concurrent stress tests (10 threads), 110+ suspend function tests with cancellation (incl. ByteArray, DataClass, List, Set, Map), and 50+ Flow tests (including `Flow<DataClass>`).
126+
Every test compiles Kotlin/Native → `libcalculator.so` (200+ exported symbols) → loads via FFM `MethodHandle` → verifies on JVM. Zero mocks — all 970+ tests cross the real native boundary. Includes 25 load tests (500K+ FFM calls), concurrent stress tests (10 threads), 110+ suspend function tests with cancellation (incl. ByteArray, DataClass, List, Set, Map), and 50+ Flow tests (including `Flow<DataClass>`).
127127

128128
| Feature | As param | As return | As property | CB param | CB return | Notes |
129129
|---------|----------|-----------|-------------|----------|-----------|-------|
@@ -396,11 +396,6 @@ Measured on Intel Core i5-14600 (20 cores), 45 GB RAM, Ubuntu 25.10, JDK 25 (Gra
396396
| Private/internal members | By design | Only public API is exported |
397397
| Expect/actual declarations | KMP's responsibility | Use platform-specific source sets |
398398

399-
### Callback limitations
400-
401-
| Unsupported | Reason | Alternative |
402-
|-------------|--------|-------------|
403-
| Lambda as return type | Callback supported as param only | Return a class with methods instead |
404399

405400

406401
## Configuration reference
@@ -565,7 +560,7 @@ The repository includes two complete examples in [`examples/`](examples/):
565560

566561
| Example | Description |
567562
|---------|-------------|
568-
| [`calculator/`](examples/calculator/) | Stateful Calculator class with 960+ end-to-end tests: all types, callbacks, collections (incl. `List<DataClass>` params), collection properties, data class collection fields, suspend (incl. DataClass, List, Set, Map), Flow (incl. DataClass), nested classes, concurrency |
563+
| [`calculator/`](examples/calculator/) | Stateful Calculator class with 970+ end-to-end tests: all types, callbacks, collections (incl. `List<DataClass>` params), collection properties, data class collection fields, suspend (incl. DataClass, List, Set, Map), Flow (incl. DataClass), nested classes, concurrency |
569564
| [`systeminfo/`](examples/systeminfo/) | Linux system info (`/proc`, POSIX, `gethostname`) + native notifications via `libnotify` cinterop, with Compose Desktop UI |
570565
| [`benchmark/`](examples/benchmark/) | Performance benchmarks: native vs JVM (fibonacci, pi, sort, string, allocation, concurrent) |
571566

@@ -574,7 +569,7 @@ Run them:
574569
```bash
575570
./gradlew :examples:calculator:run
576571
./gradlew :examples:systeminfo:run
577-
./gradlew :examples:calculator:jvmTest # 960+ end-to-end FFM tests
572+
./gradlew :examples:calculator:jvmTest # 970+ end-to-end FFM tests
578573
./gradlew :examples:benchmark:jvmTest # Performance benchmarks (native vs JVM)
579574
```
580575

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package com.example.calculator
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlinx.coroutines.runBlocking
6+
import kotlinx.coroutines.async
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.awaitAll
9+
10+
class LambdaReturnTest {
11+
12+
// ══════════════════════════════════════════════════════════════════════════
13+
// LAMBDA AS RETURN TYPE — BATTLE TESTED
14+
// ══════════════════════════════════════════════════════════════════════════
15+
16+
// ── (Int) -> Int return ────────────────────────────────────────────────
17+
18+
@Test
19+
fun `lambda return - getAdder basic`() {
20+
Calculator(0).use { calc ->
21+
val adder = calc.getAdder(5)
22+
assertEquals(15, adder(10))
23+
assertEquals(5, adder(0))
24+
assertEquals(-5, adder(-10))
25+
}
26+
}
27+
28+
@Test
29+
fun `lambda return - getAdder zero`() {
30+
Calculator(0).use { calc ->
31+
val adder = calc.getAdder(0)
32+
assertEquals(42, adder(42))
33+
}
34+
}
35+
36+
@Test
37+
fun `lambda return - getAdder negative`() {
38+
Calculator(0).use { calc ->
39+
val adder = calc.getAdder(-3)
40+
assertEquals(7, adder(10))
41+
}
42+
}
43+
44+
@Test
45+
fun `lambda return - getAdder call multiple times`() {
46+
Calculator(0).use { calc ->
47+
val adder = calc.getAdder(1)
48+
assertEquals(1, adder(0))
49+
assertEquals(2, adder(1))
50+
assertEquals(100, adder(99))
51+
assertEquals(1, adder(0))
52+
}
53+
}
54+
55+
@Test
56+
fun `lambda return - multiple adders independent`() {
57+
Calculator(0).use { calc ->
58+
val add5 = calc.getAdder(5)
59+
val add10 = calc.getAdder(10)
60+
assertEquals(15, add5(10))
61+
assertEquals(20, add10(10))
62+
assertEquals(5, add5(0))
63+
assertEquals(10, add10(0))
64+
}
65+
}
66+
67+
// ── (Int) -> String return ─────────────────────────────────────────────
68+
69+
@Test
70+
fun `lambda return - getFormatter basic`() {
71+
Calculator(42).use { calc ->
72+
val fmt = calc.getFormatter()
73+
val result = fmt(7)
74+
assertEquals("value=7 (acc=42)", result)
75+
}
76+
}
77+
78+
@Test
79+
fun `lambda return - getFormatter captures accumulator`() {
80+
Calculator(0).use { calc ->
81+
calc.add(10)
82+
val fmt = calc.getFormatter()
83+
assertEquals("value=5 (acc=10)", fmt(5))
84+
}
85+
}
86+
87+
// ── (Int) -> Unit return ───────────────────────────────────────────────
88+
89+
@Test
90+
fun `lambda return - getNotifier basic`() {
91+
Calculator(0).use { calc ->
92+
val notifier = calc.getNotifier()
93+
notifier(42)
94+
assertEquals(42, calc.current)
95+
}
96+
}
97+
98+
@Test
99+
fun `lambda return - getNotifier multiple calls`() {
100+
Calculator(0).use { calc ->
101+
val notifier = calc.getNotifier()
102+
notifier(1)
103+
assertEquals(1, calc.current)
104+
notifier(99)
105+
assertEquals(99, calc.current)
106+
}
107+
}
108+
109+
// ── Concurrent ─────────────────────────────────────────────────────────
110+
111+
@Test
112+
fun `lambda return - concurrent getAdder`() = runBlocking {
113+
Calculator(0).use { calc ->
114+
val results = (1..20).map { i ->
115+
async(Dispatchers.Default) {
116+
val adder = calc.getAdder(i)
117+
adder(100)
118+
}
119+
}.awaitAll()
120+
results.forEachIndexed { idx, r -> assertEquals(100 + idx + 1, r) }
121+
}
122+
}
123+
124+
@Test
125+
fun `lambda return - concurrent invoke same lambda`() = runBlocking {
126+
Calculator(0).use { calc ->
127+
val adder = calc.getAdder(5)
128+
val results = (1..20).map { i ->
129+
async(Dispatchers.Default) { adder(i) }
130+
}.awaitAll()
131+
results.forEachIndexed { idx, r -> assertEquals(idx + 1 + 5, r) }
132+
}
133+
}
134+
135+
// ── Sequential stress ──────────────────────────────────────────────────
136+
137+
@Test
138+
fun `lambda return - 100 sequential getAdder`() {
139+
Calculator(0).use { calc ->
140+
repeat(100) { i ->
141+
val adder = calc.getAdder(i)
142+
assertEquals(i + 1, adder(1))
143+
}
144+
}
145+
System.gc()
146+
}
147+
148+
@Test
149+
fun `lambda return - 200 sequential invocations`() {
150+
Calculator(0).use { calc ->
151+
val adder = calc.getAdder(1)
152+
repeat(200) { i ->
153+
assertEquals(i + 1, adder(i))
154+
}
155+
}
156+
System.gc()
157+
}
158+
159+
@Test
160+
fun `lambda return - 50 instances`() {
161+
repeat(50) { i ->
162+
Calculator(i).use { calc ->
163+
val adder = calc.getAdder(i)
164+
assertEquals(i * 2, adder(i))
165+
}
166+
}
167+
System.gc()
168+
}
169+
}

examples/calculator/src/nativeMain/kotlin/com/example/calculator/Calculator.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,20 @@ class Calculator(initial: Int = 0) {
215215
fun withShort(fn: (Short) -> Short): Short = fn(accumulator.toShort())
216216
fun withByte(fn: (Byte) -> Byte): Byte = fn(accumulator.toByte())
217217

218+
// ── Lambda return type ────────────────────────────────────────────────
219+
220+
fun getAdder(amount: Int): (Int) -> Int {
221+
return { x -> x + amount }
222+
}
223+
224+
fun getFormatter(): (Int) -> String {
225+
return { x -> "value=$x (acc=$accumulator)" }
226+
}
227+
228+
fun getNotifier(): (Int) -> Unit {
229+
return { x -> accumulator = x }
230+
}
231+
218232
// ── ByteArray callback params ─────────────────────────────────────────
219233

220234
fun onBytesReady(callback: (ByteArray) -> Unit) {

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleusnativeaccess/plugin/codegen/FfmProxyGenerator.kt

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ class FfmProxyGenerator {
359359
return "${params.ifEmpty { "Void" }}_to_$ret"
360360
}
361361

362+
private fun fnInvokeId(fnType: KneType.FUNCTION): String = callbackId(fnType)
363+
362364
private fun generateRuntime(libName: String, pkg: String, callbackSignatures: Set<KneType.FUNCTION> = emptySet(), hasSuspend: Boolean = false, hasFlow: Boolean = false): String = buildString {
363365
appendLine("// Auto-generated by kotlin-native-export plugin. Do not modify.")
364366
appendLine("package $pkg")
@@ -1314,6 +1316,39 @@ class FfmProxyGenerator {
13141316
appendLine(" }")
13151317
}
13161318

1319+
// Function return invoke handles
1320+
val fnReturnTypes = mutableSetOf<KneType.FUNCTION>()
1321+
cls.methods.forEach { m -> if (m.returnType is KneType.FUNCTION) fnReturnTypes.add(m.returnType as KneType.FUNCTION) }
1322+
cls.companionMethods.forEach { m -> if (m.returnType is KneType.FUNCTION) fnReturnTypes.add(m.returnType as KneType.FUNCTION) }
1323+
for (fnType in fnReturnTypes) {
1324+
val fnId = fnInvokeId(fnType)
1325+
val layouts = buildList {
1326+
add("JAVA_LONG") // handle
1327+
fnType.paramTypes.forEach { t ->
1328+
when (t) {
1329+
KneType.STRING -> add("ADDRESS")
1330+
KneType.BYTE_ARRAY -> { add("ADDRESS"); add("JAVA_INT") }
1331+
KneType.BOOLEAN -> add("JAVA_INT")
1332+
is KneType.ENUM -> add("JAVA_INT")
1333+
is KneType.OBJECT -> add("JAVA_LONG")
1334+
else -> add(t.ffmLayout)
1335+
}
1336+
}
1337+
}.joinToString(", ")
1338+
val retLayout = when (fnType.returnType) {
1339+
KneType.UNIT -> null
1340+
KneType.BOOLEAN -> "JAVA_INT"
1341+
KneType.STRING, KneType.BYTE_ARRAY -> "JAVA_LONG"
1342+
is KneType.ENUM -> "JAVA_INT"
1343+
is KneType.OBJECT -> "JAVA_LONG"
1344+
else -> fnType.returnType.ffmLayout
1345+
}
1346+
val descriptor = if (retLayout == null) "FunctionDescriptor.ofVoid($layouts)" else "FunctionDescriptor.of($retLayout, $layouts)"
1347+
appendLine(" private val INVOKE_FN_${fnId.uppercase()}_HANDLE: MethodHandle by lazy {")
1348+
appendLine(" KneRuntime.handle(\"${p}_kne_invokeFn_$fnId\", $descriptor)")
1349+
appendLine(" }")
1350+
}
1351+
13171352
// Factory
13181353
val ctorParams = cls.constructor.params.joinToString(", ") { "${it.name}: ${it.type.jvmTypeName}" }
13191354
appendLine()
@@ -3317,8 +3352,50 @@ class FfmProxyGenerator {
33173352
}
33183353
is KneType.NULLABLE -> appendNullableCallAndReturn(indent, returnType, handleName, invokeArgs)
33193354
is KneType.FUNCTION -> {
3320-
appendLine("${indent}$handleName.invoke($invokeArgs)")
3355+
val fnId = fnInvokeId(returnType)
3356+
appendLine("${indent}val _fnHandle = $handleName.invoke($invokeArgs) as Long")
33213357
appendLine("${indent}KneRuntime.checkError()")
3358+
// Wrap in a lambda that invokes the native function via the invoke bridge
3359+
val lambdaParams = returnType.paramTypes.mapIndexed { i, _ -> "_p$i" }.joinToString(", ")
3360+
val invokeHandleName = "INVOKE_FN_${fnId.uppercase()}_HANDLE"
3361+
val invokeCallArgs = buildList {
3362+
add("_fnHandle")
3363+
returnType.paramTypes.forEachIndexed { i, t ->
3364+
when (t) {
3365+
KneType.STRING -> add("Arena.ofAuto().allocateFrom(_p$i)")
3366+
KneType.BYTE_ARRAY -> { add("run { val _a = Arena.ofAuto(); val _s = _a.allocate(_p$i.size.toLong()); MemorySegment.copy(_p$i, 0, _s, JAVA_BYTE, 0, _p$i.size); _s }"); add("_p$i.size") }
3367+
KneType.BOOLEAN -> add("if (_p$i) 1 else 0")
3368+
is KneType.ENUM -> add("_p$i.ordinal")
3369+
is KneType.OBJECT -> add("_p$i.handle")
3370+
else -> add("_p$i")
3371+
}
3372+
}
3373+
}.joinToString(", ")
3374+
val retConvert = when (returnType.returnType) {
3375+
KneType.UNIT -> ""
3376+
KneType.BOOLEAN -> " != 0"
3377+
KneType.STRING -> ".let { KneRuntime.readStringFromRef(it as Long) }"
3378+
KneType.BYTE_ARRAY -> ".let { KneRuntime.readByteArrayFromRef(it as Long) }"
3379+
is KneType.ENUM -> ".let { ${returnType.returnType.simpleName}.entries[it as Int] }"
3380+
is KneType.OBJECT -> ".let { ${returnType.returnType.simpleName}.fromNativeHandle(it as Long) }"
3381+
else -> ""
3382+
}
3383+
if (returnType.returnType == KneType.UNIT) {
3384+
appendLine("${indent}return { $lambdaParams -> $invokeHandleName.invoke($invokeCallArgs); Unit }")
3385+
} else {
3386+
val castExpr = when (returnType.returnType) {
3387+
KneType.INT -> " as Int"
3388+
KneType.LONG -> " as Long"
3389+
KneType.DOUBLE -> " as Double"
3390+
KneType.FLOAT -> " as Float"
3391+
KneType.SHORT -> " as Short"
3392+
KneType.BYTE -> " as Byte"
3393+
KneType.BOOLEAN, is KneType.ENUM -> " as Int"
3394+
KneType.STRING, KneType.BYTE_ARRAY, is KneType.OBJECT -> " as Long"
3395+
else -> ""
3396+
}
3397+
appendLine("${indent}return { $lambdaParams -> ($invokeHandleName.invoke($invokeCallArgs)$castExpr)$retConvert }")
3398+
}
33223399
}
33233400
is KneType.DATA_CLASS -> {
33243401
// DATA_CLASS returns are handled separately in appendMethodProxy

0 commit comments

Comments
 (0)