Implement init blocks for instance initialization, refactor class initialization logic to ensure correct inheritance and constructor chaining, update documentation and add tests.

This commit is contained in:
Sergey Chernov 2025-12-21 18:14:51 +01:00
parent 35cc8fa930
commit 76a1804dc1
9 changed files with 260 additions and 68 deletions

View File

@ -42,6 +42,34 @@ a _constructor_ that requires two parameters for fields. So when creating it wit
Form now on `Point` is a class, it's type is `Class`, and we can create instances with it as in the
example above.
## Instance initialization: init block
In addition to the primary constructor arguments, you can provide an `init` block that runs on each instance creation. This is useful for more complex initializations, side effects, or setting up fields that depend on multiple constructor parameters.
class Point(val x, val y) {
var magnitude
init {
magnitude = Math.sqrt(x*x + y*y)
}
}
Key features of `init` blocks:
- **Scope**: They have full access to `this` members and all primary constructor parameters.
- **Order**: In a single-inheritance scenario, `init` blocks run immediately after the instance fields are prepared but before the primary constructor body logic.
- **Multiple blocks**: You can have multiple `init` blocks; they will be executed in the order they appear in the class body.
### Initialization in Multiple Inheritance
In cases of multiple inheritance, `init` blocks are executed following the constructor chaining rule:
1. All ancestors are initialized first, following the inheritance hierarchy (diamond-safe: each ancestor is initialized exactly once).
2. The `init` blocks of each class are executed after its parents have been fully initialized.
3. For a hierarchy `class D : B, C`, the initialization order is: `B`'s chain, then `C`'s chain (skipping common ancestors with `B`), and finally `D`'s own `init` blocks.
### Initialization during Deserialization
When an object is restored from a serialized form (e.g., using `Lynon`), `init` blocks are **re-executed**. This ensures that transient state or derived fields are correctly recalculated upon restoration. However, primary constructors are **not** re-called during deserialization; only the `init` blocks and field initializers are executed to restore the instance state.
Class point has a _method_, or a _member function_ `length()` that uses its _fields_ `x` and `y` to
calculate the magnitude. Length is called

View File

@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych"
version = "1.0.8-SNAPSHOT"
version = "1.0.9-SNAPSHOT"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below

View File

@ -1353,6 +1353,23 @@ class Compiler(
parseClassDeclaration()
}
"init" -> {
if (codeContexts.lastOrNull() is CodeContext.ClassBody) {
val block = parseBlock()
lastParsedBlockRange?.let { range ->
miniSink?.onInitDecl(MiniInitDecl(MiniRange(id.pos, range.end), id.pos))
}
val initStmt = statement(id.pos) { scp ->
block.execute(scp)
ObjVoid
}
statement {
currentClassCtx?.instanceInitializers?.add(initStmt)
ObjVoid
}
} else null
}
"enum" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
@ -1754,7 +1771,7 @@ class Compiler(
val constructorArgsDeclaration =
if (cc.skipTokenOfType(Token.Type.LPAREN, isOptional = true))
parseArgsDeclaration(isClassDeclaration = true)
else null
else ArgsDeclaration(emptyList(), Token.Type.RPAREN)
if (constructorArgsDeclaration != null && constructorArgsDeclaration.endTokenType != Token.Type.RPAREN)
throw ScriptError(
@ -1791,7 +1808,9 @@ class Compiler(
if (next.type == Token.Type.LBRACE) {
// parse body
val bodyStart = next.pos
val st = parseScript()
val st = withLocalNames(constructorArgsDeclaration?.params?.map { it.name }?.toSet() ?: emptySet()) {
parseScript()
}
val rbTok = cc.next()
if (rbTok.type != Token.Type.RBRACE) throw ScriptError(rbTok.pos, "unbalanced braces in class body")
classBodyRange = MiniRange(bodyStart, rbTok.pos)

View File

@ -220,6 +220,16 @@ class Script(
ObjDynamic.create(this, requireOnlyArg())
}
val root = this
val mathClass = ObjClass("Math").apply {
addFn("sqrt") {
ObjReal(sqrt(args.firstAndOnly().toDouble()))
}
}
addItem("Math", false, ObjInstance(mathClass).apply {
instanceScope = Scope(root, thisObj = this)
})
addFn("require") {
val condition = requiredArg<ObjBool>(0)
if (!condition.value) {

View File

@ -183,6 +183,7 @@ object CompletionEngineLight {
val ci = CompletionItem(name, Kind.Field, typeText = typeOf((chosen as MiniMemberValDecl).type))
if (ci.name.startsWith(prefix, true)) out += ci
}
is MiniInitDecl -> {}
}
}
}
@ -210,6 +211,7 @@ object CompletionEngineLight {
if (ci.name.startsWith(prefix, true)) out += ci
already.add(name)
}
is MiniInitDecl -> {}
}
} else {
// Fallback: emit simple method name without detailed types
@ -302,6 +304,7 @@ object CompletionEngineLight {
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}
@ -362,6 +365,7 @@ object CompletionEngineLight {
val ret = when (member) {
is MiniMemberFunDecl -> member.returnType
is MiniMemberValDecl -> member.type
is MiniInitDecl -> null
}
return simpleClassNameOf(ret)
}

View File

@ -183,6 +183,15 @@ data class MiniMemberValDecl(
override val isStatic: Boolean = false,
) : MiniMemberDecl
data class MiniInitDecl(
override val range: MiniRange,
override val nameStart: Pos,
) : MiniMemberDecl {
override val name: String get() = "init"
override val doc: MiniDoc? get() = null
override val isStatic: Boolean get() = false
}
// Streaming sink to collect mini-AST during parsing. Implementations may assemble a tree or process events.
interface MiniAstSink {
fun onScriptStart(start: Pos) {}
@ -193,6 +202,7 @@ interface MiniAstSink {
fun onImport(node: MiniImport) {}
fun onFunDecl(node: MiniFunDecl) {}
fun onValDecl(node: MiniValDecl) {}
fun onInitDecl(node: MiniInitDecl) {}
fun onClassDecl(node: MiniClassDecl) {}
fun onBlock(node: MiniBlock) {}

View File

@ -200,6 +200,16 @@ open class ObjClass(
override suspend fun compareTo(scope: Scope, other: Obj): Int = if (other === this) 0 else -1
override suspend fun callOn(scope: Scope): Obj {
val instance = createInstance(scope)
initializeInstance(instance, scope.args, runConstructors = true)
return instance
}
/**
* Create an instance of this class and initialize its [ObjInstance.instanceScope] with
* methods. Does NOT run initializers or constructors.
*/
internal fun createInstance(scope: Scope): ObjInstance {
val instance = ObjInstance(this)
// Avoid capturing a transient (pooled) call frame as the parent of the instance scope.
// Bind instance scope to the caller's parent chain directly so name resolution (e.g., stdlib like sqrt)
@ -223,14 +233,38 @@ open class ObjClass(
}
}
}
// Constructor chaining MVP: initialize base classes left-to-right, then this class.
// Ensure each ancestor is initialized at most once (diamond-safe).
val visited = hashSetOf<ObjClass>()
return instance
}
suspend fun initClass(c: ObjClass, argsForThis: Arguments?, isRoot: Boolean = false) {
/**
* Run initializers and optionally constructors for the given [instance].
* Handles Multiple Inheritance correctly (diamond-safe).
*/
internal suspend fun initializeInstance(
instance: ObjInstance,
args: Arguments?,
runConstructors: Boolean
) {
val visited = hashSetOf<ObjClass>()
initClassInternal(instance, visited, this, args, isRoot = true, runConstructors = runConstructors)
}
private suspend fun initClassInternal(
instance: ObjInstance,
visited: MutableSet<ObjClass>,
c: ObjClass,
argsForThis: Arguments?,
@Suppress("UNUSED_PARAMETER") isRoot: Boolean = false,
runConstructors: Boolean = true
) {
if (!visited.add(c)) return
// For the most-derived class, bind its constructor params BEFORE parents so base arg thunks can see them
if (isRoot) {
// Bind constructor parameters (both mangled and unmangled)
// These are needed for:
// 1) base constructor argument evaluation (if called from a derived class)
// 2) this class's field initializers and `init` blocks
// 3) this class's constructor body
// 4) `compareTo` and other structural operations
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
// Assign constructor params into instance scope (unmangled)
@ -242,12 +276,12 @@ open class ObjClass(
if (rec != null) {
val mangled = "${c.className}::${p.name}"
// Always point the mangled name to the current record to keep writes consistent
// across re-bindings (e.g., second pass before ctor)
// across re-bindings
instance.instanceScope.objects[mangled] = rec
}
}
}
}
// Initialize direct parents first, in order
for (p in c.directParents) {
val raw = c.directParentArgs[p]?.toArguments(instance.instanceScope, false)
@ -255,8 +289,25 @@ open class ObjClass(
val need = p.constructorMeta?.params?.size ?: 0
if (need == 0) Arguments.EMPTY else Arguments(raw.list.take(need), tailBlockMode = false)
} else Arguments.EMPTY
initClass(p, limited)
initClassInternal(instance, visited, p, limited, false, runConstructors)
}
// Re-bind this class's parameters right before running its initializers and constructor.
// This ensures that unmangled names in the instance scope correctly refer to THIS class's
// parameters even if they were shadowed/overwritten by parent class initialization.
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
meta.assignToContext(instance.instanceScope, argsHere)
// Re-sync mangled names to point to the fresh records to keep them consistent
for (p in meta.params) {
val rec = instance.instanceScope.objects[p.name]
if (rec != null) {
val mangled = "${c.className}::${p.name}"
instance.instanceScope.objects[mangled] = rec
}
}
}
// Execute per-instance initializers collected from class body for this class
if (c.instanceInitializers.isNotEmpty()) {
val savedCtx = instance.instanceScope.currentClassCtx
@ -270,28 +321,13 @@ open class ObjClass(
}
}
// Then run this class' constructor, if any
if (runConstructors) {
c.instanceConstructor?.let { ctor ->
// Bind this class's constructor parameters into the instance scope now, right before ctor
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
meta.assignToContext(instance.instanceScope, argsHere)
// Ensure mangled aliases exist for qualified access starting from this class
for (p in meta.params) {
val rec = instance.instanceScope.objects[p.name]
if (rec != null) {
val mangled = "${c.className}::${p.name}"
// Overwrite to ensure alias refers to the latest ObjRecord after re-binding
instance.instanceScope.objects[mangled] = rec
}
}
}
val execScope = instance.instanceScope.createChildScope(args = argsForThis ?: Arguments.EMPTY, newThisObj = instance)
val execScope =
instance.instanceScope.createChildScope(args = argsForThis ?: Arguments.EMPTY, newThisObj = instance)
ctor.execute(execScope)
}
}
initClass(this, instance.instanceScope.args, isRoot = true)
return instance
}
suspend fun callWithArgs(scope: Scope, vararg plainArgs: Obj): Obj {

View File

@ -33,7 +33,9 @@ class ObjInstanceClass(val name: String, vararg parents: ObjClass) : ObjClass(na
if (args.size > actualSize)
scope.raiseIllegalArgument("constructor $name has only $actualSize but serialized version has ${args.size}")
val newScope = scope.createChildScope(args = Arguments(args))
return (callOn(newScope) as ObjInstance).apply {
val instance = createInstance(newScope)
initializeInstance(instance, newScope.args, runConstructors = false)
return instance.apply {
deserializeStateVars(scope, decoder)
invokeInstanceMethod(scope, "onDeserialized") { ObjVoid }
}

View File

@ -186,4 +186,87 @@ class OOTest {
assertEquals(15, cc.foo.bar(10,2,3))
""")
}
@Test
fun testClassInitialization() = runTest {
eval("""
var countInstances = 0
class Point(val x: Int, val y: Int) {
println("Class initializer is called 1")
var magnitude
/*
init {} section optionally provide initialization code that is called on each instance creation.
it should have the same access to this.* and constructor parameters as any other member
function.
*/
init {
countInstances++
magnitude = Math.sqrt(x*x + y*y)
}
}
val p = Point(1, 2)
assertEquals(1, countInstances)
assertEquals(p, Point(1,2) )
assertEquals(2, countInstances)
""".trimIndent())
}
@Test
fun testMIInitialization() = runTest {
eval("""
var order = []
class A {
init { order.add("A") }
}
class B : A {
init { order.add("B") }
}
class C {
init { order.add("C") }
}
class D : B, C {
init { order.add("D") }
}
D()
assertEquals(["A", "B", "C", "D"], order)
""")
}
@Test
fun testMIDiamondInitialization() = runTest {
eval("""
var order = []
class A {
init { order.add("A") }
}
class B : A {
init { order.add("B") }
}
class C : A {
init { order.add("C") }
}
class D : B, C {
init { order.add("D") }
}
D()
assertEquals(["A", "B", "C", "D"], order)
""")
}
@Test
fun testInitBlockInDeserialization() = runTest {
eval("""
import lyng.serialization
var count = 0
class A {
init { count++ }
}
val a1 = A()
val coded = Lynon.encode(a1)
val a2 = Lynon.decode(coded)
assertEquals(2, count)
""")
}
}