diff --git a/docs/OOP.md b/docs/OOP.md index c76c011..9895a9b 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -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 diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index ccb5d87..bbc4d9f 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index d507a04..b1163e6 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -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) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 1626253..2fa7a82 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -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(0) if (!condition.value) { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt index b9f63ca..4928eda 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/CompletionEngineLight.kt @@ -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) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt index 0ce422b..10a7b1a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt @@ -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) {} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt index 1b5d7eb..698b183 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt @@ -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,75 +233,101 @@ 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() + return instance + } - suspend fun initClass(c: ObjClass, argsForThis: Arguments?, isRoot: Boolean = false) { - 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) { - c.constructorMeta?.let { meta -> - val argsHere = argsForThis ?: Arguments.EMPTY - // Assign constructor params into instance scope (unmangled) - meta.assignToContext(instance.instanceScope, argsHere) - // Also expose them under MI-mangled storage keys `${Class}::name` so qualified views can access them - // and so that base-class casts like `(obj as Base).field` work. - for (p in meta.params) { - val rec = instance.instanceScope.objects[p.name] - 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) - instance.instanceScope.objects[mangled] = rec - } - } + /** + * 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() + initClassInternal(instance, visited, this, args, isRoot = true, runConstructors = runConstructors) + } + + private suspend fun initClassInternal( + instance: ObjInstance, + visited: MutableSet, + c: ObjClass, + argsForThis: Arguments?, + @Suppress("UNUSED_PARAMETER") isRoot: Boolean = false, + runConstructors: Boolean = true + ) { + if (!visited.add(c)) return + + // 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) + meta.assignToContext(instance.instanceScope, argsHere) + // Also expose them under MI-mangled storage keys `${Class}::name` so qualified views can access them + // and so that base-class casts like `(obj as Base).field` work. + for (p in meta.params) { + val rec = instance.instanceScope.objects[p.name] + 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 + 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) - val limited = if (raw != null) { - 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) - } - // Execute per-instance initializers collected from class body for this class - if (c.instanceInitializers.isNotEmpty()) { - val savedCtx = instance.instanceScope.currentClassCtx - instance.instanceScope.currentClassCtx = c - try { - for (initStmt in c.instanceInitializers) { - initStmt.execute(instance.instanceScope) - } - } finally { - instance.instanceScope.currentClassCtx = savedCtx - } - } - // Then run this class' constructor, if any - 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) - ctor.execute(execScope) - } } - initClass(this, instance.instanceScope.args, isRoot = true) - return instance + // Initialize direct parents first, in order + for (p in c.directParents) { + val raw = c.directParentArgs[p]?.toArguments(instance.instanceScope, false) + val limited = if (raw != null) { + val need = p.constructorMeta?.params?.size ?: 0 + if (need == 0) Arguments.EMPTY else Arguments(raw.list.take(need), tailBlockMode = false) + } else Arguments.EMPTY + 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 + instance.instanceScope.currentClassCtx = c + try { + for (initStmt in c.instanceInitializers) { + initStmt.execute(instance.instanceScope) + } + } finally { + instance.instanceScope.currentClassCtx = savedCtx + } + } + // Then run this class' constructor, if any + if (runConstructors) { + c.instanceConstructor?.let { ctor -> + val execScope = + instance.instanceScope.createChildScope(args = argsForThis ?: Arguments.EMPTY, newThisObj = instance) + ctor.execute(execScope) + } + } } suspend fun callWithArgs(scope: Scope, vararg plainArgs: Obj): Obj { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt index 0c4c01e..3597766 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt @@ -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 } } diff --git a/lynglib/src/commonTest/kotlin/OOTest.kt b/lynglib/src/commonTest/kotlin/OOTest.kt index 0804683..716d048 100644 --- a/lynglib/src/commonTest/kotlin/OOTest.kt +++ b/lynglib/src/commonTest/kotlin/OOTest.kt @@ -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) + """) + } } \ No newline at end of file