Step 26A: bytecode lambda callables

This commit is contained in:
Sergey Chernov 2026-02-11 20:50:56 +03:00
parent 99ca15d20f
commit bde32ca7b5
11 changed files with 189 additions and 8 deletions

View File

@ -121,9 +121,9 @@ Goal: migrate the compiler so all values live in frames/bytecode, keeping JVM te
- [x] Replace `emitStatementCall` usage for `FunctionDeclStatement`.
- [x] Add JVM disasm coverage to ensure module init has no `CALL_SLOT` to `Callable@...` for declarations.
- [ ] Step 26: Bytecode-backed lambdas (remove `ValueFnRef` runtime execution).
- [ ] Compile lambda bodies to bytecode and emit an opcode to create a callable from bytecode + capture plan.
- [x] Compile lambda bodies to bytecode and emit an opcode to create a callable from bytecode + capture plan.
- [ ] Remove `containsValueFnRef`/`forceScopeSlots` workaround once lambdas are bytecode.
- [ ] Add JVM tests for captured locals and delegated locals inside lambdas on the bytecode path.
- [x] Add JVM tests for captured locals and delegated locals inside lambdas on the bytecode path.
- [ ] Step 27: Remove interpreter opcodes and constants from bytecode runtime.
- [ ] Delete `BytecodeConst.ValueFn`, `CmdMakeValueFn`, and `MAKE_VALUE_FN`.
- [ ] Delete `BytecodeConst.StatementVal`, `CmdEvalStmt`, and `EVAL_STMT`.

View File

@ -0,0 +1,19 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sergeych.lyng
interface BytecodeCallable

View File

@ -2918,7 +2918,9 @@ class Compiler(
} else {
body
}
val ref = ValueFnRef { closureScope ->
val bytecodeFn = (fnStatements as? BytecodeStatement)?.bytecodeFunction()
val ref = LambdaFnRef(
valueFn = { closureScope ->
val captureRecords = closureScope.captureRecords
val stmt = object : Statement(), BytecodeBodyProvider {
override val pos: Pos = fnStatements.pos
@ -3009,7 +3011,15 @@ class Compiler(
stmt
}
callable.asReadonly
}
},
bytecodeFn = bytecodeFn,
paramSlotPlan = paramSlotPlanSnapshot,
argsDeclaration = argsDeclaration,
preferredThisType = expectedReceiverType,
wrapAsExtensionCallable = wrapAsExtensionCallable,
returnLabels = returnLabels,
pos = startPos
)
if (returnClass != null) {
lambdaReturnTypeByRef[ref] = returnClass
}

View File

@ -623,6 +623,42 @@ class BytecodeCompiler(
}
private fun compileValueFnRef(ref: ValueFnRef): CompiledValue? {
if (ref is LambdaFnRef && ref.bytecodeFn != null) {
val captures = lambdaCaptureEntriesByRef[ref].orEmpty()
val captureTableId = if (captures.isEmpty()) {
null
} else {
val resolved = captures.map { entry ->
val slotIndex = resolveCaptureSlot(entry)
BytecodeCaptureEntry(
ownerKind = entry.ownerKind,
ownerScopeId = entry.ownerScopeId,
ownerSlotId = entry.ownerSlotId,
slotIndex = slotIndex
)
}
builder.addConst(BytecodeConst.CaptureTable(resolved))
}
val captureNames = captures.map { it.ownerName }
val id = builder.addConst(
BytecodeConst.LambdaFn(
fn = ref.bytecodeFn,
captureTableId = captureTableId,
captureNames = captureNames,
paramSlotPlan = ref.paramSlotPlan,
argsDeclaration = ref.argsDeclaration,
preferredThisType = ref.preferredThisType,
wrapAsExtensionCallable = ref.wrapAsExtensionCallable,
returnLabels = ref.returnLabels,
pos = ref.pos
)
)
val slot = allocSlot()
builder.emit(Opcode.MAKE_LAMBDA_FN, id, slot)
updateSlotType(slot, SlotType.OBJ)
return CompiledValue(slot, SlotType.OBJ)
}
val captureTableId = lambdaCaptureEntriesByRef[ref]?.let { captures ->
if (captures.isEmpty()) return@let null
val resolved = captures.map { entry ->

View File

@ -16,6 +16,7 @@
package net.sergeych.lyng.bytecode
import net.sergeych.lyng.ArgsDeclaration
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Visibility
import net.sergeych.lyng.obj.ListLiteralRef
@ -37,6 +38,17 @@ sealed class BytecodeConst {
val fn: suspend (net.sergeych.lyng.Scope) -> net.sergeych.lyng.obj.ObjRecord,
val captureTableId: Int? = null,
) : BytecodeConst()
data class LambdaFn(
val fn: CmdFunction,
val captureTableId: Int?,
val captureNames: List<String>,
val paramSlotPlan: Map<String, Int>,
val argsDeclaration: ArgsDeclaration?,
val preferredThisType: String?,
val wrapAsExtensionCallable: Boolean,
val returnLabels: Set<String>,
val pos: Pos,
) : BytecodeConst()
data class DeclExec(val executable: net.sergeych.lyng.DeclExecutable) : BytecodeConst()
data class EnumDecl(
val declaredName: String,

View File

@ -154,7 +154,7 @@ class CmdBuilder {
Opcode.CONST_NULL ->
listOf(OperandKind.SLOT)
Opcode.CONST_OBJ, Opcode.CONST_INT, Opcode.CONST_REAL, Opcode.CONST_BOOL,
Opcode.MAKE_VALUE_FN ->
Opcode.MAKE_VALUE_FN, Opcode.MAKE_LAMBDA_FN ->
listOf(OperandKind.CONST, OperandKind.SLOT)
Opcode.PUSH_SCOPE, Opcode.PUSH_SLOT_PLAN ->
listOf(OperandKind.CONST)
@ -255,6 +255,7 @@ class CmdBuilder {
Opcode.CONST_BOOL -> CmdConstBool(operands[0], operands[1])
Opcode.CONST_NULL -> CmdConstNull(operands[0])
Opcode.MAKE_VALUE_FN -> CmdMakeValueFn(operands[0], operands[1])
Opcode.MAKE_LAMBDA_FN -> CmdMakeLambda(operands[0], operands[1])
Opcode.BOX_OBJ -> CmdBoxObj(operands[0], operands[1])
Opcode.OBJ_TO_BOOL -> CmdObjToBool(operands[0], operands[1])
Opcode.RANGE_INT_BOUNDS -> CmdRangeIntBounds(operands[0], operands[1], operands[2], operands[3])

View File

@ -87,6 +87,7 @@ object CmdDisassembler {
is CmdLoadThisVariant -> Opcode.LOAD_THIS_VARIANT to intArrayOf(cmd.typeId, cmd.dst)
is CmdConstNull -> Opcode.CONST_NULL to intArrayOf(cmd.dst)
is CmdMakeValueFn -> Opcode.MAKE_VALUE_FN to intArrayOf(cmd.id, cmd.dst)
is CmdMakeLambda -> Opcode.MAKE_LAMBDA_FN to intArrayOf(cmd.id, cmd.dst)
is CmdBoxObj -> Opcode.BOX_OBJ to intArrayOf(cmd.src, cmd.dst)
is CmdObjToBool -> Opcode.OBJ_TO_BOOL to intArrayOf(cmd.src, cmd.dst)
is CmdCheckIs -> Opcode.CHECK_IS to intArrayOf(cmd.objSlot, cmd.typeSlot, cmd.dst)
@ -280,7 +281,7 @@ object CmdDisassembler {
Opcode.CONST_NULL ->
listOf(OperandKind.SLOT)
Opcode.CONST_OBJ, Opcode.CONST_INT, Opcode.CONST_REAL, Opcode.CONST_BOOL,
Opcode.MAKE_VALUE_FN ->
Opcode.MAKE_VALUE_FN, Opcode.MAKE_LAMBDA_FN ->
listOf(OperandKind.CONST, OperandKind.SLOT)
Opcode.PUSH_SCOPE, Opcode.PUSH_SLOT_PLAN ->
listOf(OperandKind.CONST)

View File

@ -1505,7 +1505,7 @@ class CmdCallSlot(
} else {
// Pooling for Statement-based callables (lambdas) can still alter closure semantics; keep safe path for now.
val scope = frame.ensureScope()
if (callee is Statement && callee !is BytecodeStatement) {
if (callee is Statement && callee !is BytecodeStatement && callee !is BytecodeCallable) {
frame.syncFrameToScope(useRefs = true)
}
callee.callOn(scope.createChildScope(scope.pos, args = args))
@ -1892,6 +1892,74 @@ class CmdMakeValueFn(internal val id: Int, internal val dst: Int) : Cmd() {
}
}
class CmdMakeLambda(internal val id: Int, internal val dst: Int) : Cmd() {
override suspend fun perform(frame: CmdFrame) {
val lambdaConst = frame.fn.constants.getOrNull(id) as? BytecodeConst.LambdaFn
?: error("MAKE_LAMBDA_FN expects LambdaFn at $id")
val scope = frame.ensureScope()
val captureRecords = lambdaConst.captureTableId?.let { frame.buildCaptureRecords(it) }
val stmt = BytecodeLambdaCallable(
fn = lambdaConst.fn,
closureScope = scope,
captureRecords = captureRecords,
captureNames = lambdaConst.captureNames,
paramSlotPlan = lambdaConst.paramSlotPlan,
argsDeclaration = lambdaConst.argsDeclaration,
preferredThisType = lambdaConst.preferredThisType,
returnLabels = lambdaConst.returnLabels,
pos = lambdaConst.pos
)
val callable: Obj = if (lambdaConst.wrapAsExtensionCallable) {
ObjExtensionMethodCallable("<lambda>", stmt)
} else {
stmt
}
frame.storeObjResult(dst, callable.asReadonly.value)
return
}
}
class BytecodeLambdaCallable(
private val fn: CmdFunction,
private val closureScope: Scope,
private val captureRecords: List<ObjRecord>?,
private val captureNames: List<String>,
private val paramSlotPlan: Map<String, Int>,
private val argsDeclaration: ArgsDeclaration?,
private val preferredThisType: String?,
private val returnLabels: Set<String>,
override val pos: Pos,
) : Statement(), BytecodeCallable {
override suspend fun execute(scope: Scope): Obj {
val context = scope.applyClosureForBytecode(closureScope, preferredThisType).also {
it.args = scope.args
}
if (paramSlotPlan.isNotEmpty()) context.applySlotPlan(paramSlotPlan)
if (captureRecords != null) {
context.captureRecords = captureRecords
context.captureNames = captureNames
} else if (captureNames.isNotEmpty()) {
closureScope.raiseIllegalState("bytecode lambda capture records missing")
}
if (argsDeclaration == null) {
val l = scope.args.list
val itValue: Obj = when (l.size) {
0 -> ObjVoid
1 -> l[0]
else -> ObjList(l.toMutableList())
}
context.addItem("it", false, itValue, recordType = ObjRecord.Type.Argument)
} else {
argsDeclaration.assignToContext(context, scope.args, defaultAccessType = AccessType.Val)
}
return try {
CmdVm().execute(fn, context, scope.args.list)
} catch (e: ReturnException) {
if (e.label == null || returnLabels.contains(e.label)) e.result else throw e
}
}
}
class CmdIterPush(internal val iterSlot: Int) : Cmd() {
override suspend fun perform(frame: CmdFrame) {
frame.pushIterator(frame.slotToObj(iterSlot))

View File

@ -42,6 +42,7 @@ enum class Opcode(val code: Int) {
CHECK_IS(0x15),
ASSERT_IS(0x16),
MAKE_QUALIFIED_VIEW(0x17),
MAKE_LAMBDA_FN(0x18),
ADD_INT(0x20),
SUB_INT(0x21),

View File

@ -0,0 +1,33 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sergeych.lyng.obj
import net.sergeych.lyng.ArgsDeclaration
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.bytecode.CmdFunction
class LambdaFnRef(
valueFn: suspend (Scope) -> ObjRecord,
val bytecodeFn: CmdFunction?,
val paramSlotPlan: Map<String, Int>,
val argsDeclaration: ArgsDeclaration?,
val preferredThisType: String?,
val wrapAsExtensionCallable: Boolean,
val returnLabels: Set<String>,
val pos: Pos,
) : ValueFnRef(valueFn)

View File

@ -72,7 +72,7 @@ sealed interface ObjRef {
}
/** Runtime-computed read-only reference backed by a lambda. */
class ValueFnRef(private val fn: suspend (Scope) -> ObjRecord) : ObjRef {
open class ValueFnRef(private val fn: suspend (Scope) -> ObjRecord) : ObjRef {
internal fun valueFn(): suspend (Scope) -> ObjRecord = fn
override suspend fun get(scope: Scope): ObjRecord = fn(scope)