From 54af50d6d646e12d705198b4b8984a3f6754e9f3 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sat, 3 Jan 2026 22:05:29 +0100 Subject: [PATCH] Implement support for late-initialized val fields in classes. Added ObjUnset, UnsetException, compile-time initialization checks, and write-once enforcement for val fields. --- docs/OOP.md | 38 +++++++++ .../kotlin/net/sergeych/lyng/CodeContext.kt | 4 +- .../kotlin/net/sergeych/lyng/Compiler.kt | 84 +++++++++++++------ .../kotlin/net/sergeych/lyng/Scope.kt | 3 + .../kotlin/net/sergeych/lyng/Script.kt | 1 + .../kotlin/net/sergeych/lyng/obj/Obj.kt | 44 ++++++++++ .../net/sergeych/lyng/obj/ObjException.kt | 7 +- .../net/sergeych/lyng/obj/ObjInstance.kt | 8 +- .../kotlin/net/sergeych/lyng/obj/ObjRef.kt | 6 +- lynglib/src/commonTest/kotlin/OOTest.kt | 47 +++++++++++ 10 files changed, 206 insertions(+), 36 deletions(-) diff --git a/docs/OOP.md b/docs/OOP.md index 4cf83ad..97c2372 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -175,6 +175,44 @@ statements discussed later, there could be default values, ellipsis, etc. Note that unlike **Kotlin**, which uses `=` for named arguments, Lyng uses `:` to avoid ambiguity with assignment expressions. +### Late-initialized `val` fields + +You can declare a `val` field without an immediate initializer if you provide an assignment for it within an `init` block or the class body. This is useful when the initial value depends on logic that cannot be expressed in a single expression. + +```kotlin +class DataProcessor(data: Object) { + val result: Object + + init { + // Complex initialization logic + result = transform(data) + } +} +``` + +Key rules for late-init `val`: +- **Compile-time Check**: The compiler ensures that every `val` declared without an initializer in a class body has at least one assignment within that class body (including `init` blocks). Failing to do so results in a syntax error. +- **Write-Once**: A `val` can only be assigned once. Even if it was declared without an initializer, once it is assigned a value (e.g., in `init`), any subsequent assignment will throw an `IllegalAssignmentException`. +- **Access before Initialization**: If you attempt to read a late-init `val` before it has been assigned (for example, by calling a method in `init` that reads the field before its assignment), it will hold a special `Unset` value. Using `Unset` for most operations (like arithmetic or method calls) will throw an `UnsetException`. +- **No Extensions**: Extension properties do not support late initialization as they do not have per-instance storage. Extension `val`s must always have an initializer or a `get()` accessor. + +### The `Unset` singleton + +The `Unset` singleton represents a field that has been declared but not yet initialized. While it can be compared and converted to a string, most other operations on it are forbidden to prevent accidental use of uninitialized data. + +```kotlin +class T { + val x + fun check() { + if (x == Unset) println("Not ready") + } + init { + check() // Prints "Not ready" + x = 42 + } +} +``` + ## Methods Functions defined inside a class body are methods, and unless declared diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CodeContext.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CodeContext.kt index 4f4ea71..8dcd8a5 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CodeContext.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CodeContext.kt @@ -20,5 +20,7 @@ package net.sergeych.lyng sealed class CodeContext { class Module(@Suppress("unused") val packageName: String?): CodeContext() class Function(val name: String): CodeContext() - class ClassBody(val name: String): CodeContext() + class ClassBody(val name: String): CodeContext() { + val pendingInitializations = mutableMapOf() + } } \ No newline at end of file diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index f592ca0..5c0acb0 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -138,9 +138,16 @@ class Compiler( private var lastParsedBlockRange: MiniRange? = null private suspend fun inCodeContext(context: CodeContext, f: suspend () -> T): T { - return try { - codeContexts.add(context) - f() + codeContexts.add(context) + try { + val res = f() + if (context is CodeContext.ClassBody) { + if (context.pendingInitializations.isNotEmpty()) { + val (name, pos) = context.pendingInitializations.entries.first() + throw ScriptError(pos, "val '$name' must be initialized in the class body or init block") + } + } + return res } finally { codeContexts.removeLast() } @@ -360,7 +367,21 @@ class Compiler( val rvalue = parseExpressionLevel(level + 1) ?: throw ScriptError(opToken.pos, "Expecting expression") - lvalue = op.generate(opToken.pos, lvalue!!, rvalue) + val res = op.generate(opToken.pos, lvalue!!, rvalue) + if (opToken.type == Token.Type.ASSIGN) { + val ctx = codeContexts.lastOrNull() + if (ctx is CodeContext.ClassBody) { + val target = lvalue + val name = when (target) { + is LocalVarRef -> target.name + is FastLocalVarRef -> target.name + is FieldRef -> if (target.target is LocalVarRef && target.target.name == "this") target.name else null + else -> null + } + if (name != null) ctx.pendingInitializations.remove(name) + } + } + lvalue = res } return lvalue } @@ -2714,7 +2735,13 @@ class Compiler( if (!isProperty && eqToken.type != Token.Type.ASSIGN) { if (!isMutable && (declaringClassNameCaptured == null) && (extTypeName == null)) throw ScriptError(start, "val must be initialized") - else { + else if (!isMutable && declaringClassNameCaptured != null && extTypeName == null) { + // lateinit val in class: track it + (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.pendingInitializations?.put(name, start) + cc.restorePos(markBeforeEq) + cc.skipWsTokens() + setNull = true + } else { cc.restorePos(markBeforeEq) cc.skipWsTokens() setNull = true @@ -2883,34 +2910,37 @@ class Compiler( prop } } else { - if (declaringClassName != null && !isStatic) { - val storageName = "$declaringClassName::$name" - // If we are in class scope now (defining instance field), defer initialization to instance time - val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance) - if (isClassScope) { - val cls = context.thisObj as ObjClass - // Defer: at instance construction, evaluate initializer in instance scope and store under mangled name - val initStmt = statement(start) { scp -> - val initValue = initialExpression?.execute(scp)?.byValueCopy() ?: ObjNull - // Preserve mutability of declaration: do NOT use addOrUpdateItem here, as it creates mutable records - scp.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) + val isLateInitVal = !isMutable && initialExpression == null && getter == null && setter == null + if (declaringClassName != null && !isStatic) { + val storageName = "$declaringClassName::$name" + // If we are in class scope now (defining instance field), defer initialization to instance time + val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance) + if (isClassScope) { + val cls = context.thisObj as ObjClass + // Defer: at instance construction, evaluate initializer in instance scope and store under mangled name + val initStmt = statement(start) { scp -> + val initValue = + initialExpression?.execute(scp)?.byValueCopy() ?: if (isLateInitVal) ObjUnset else ObjNull + // Preserve mutability of declaration: do NOT use addOrUpdateItem here, as it creates mutable records + scp.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) + ObjVoid + } + cls.instanceInitializers += initStmt ObjVoid + } else { + // We are in instance scope already: perform initialization immediately + val initValue = + initialExpression?.execute(context)?.byValueCopy() ?: if (isLateInitVal) ObjUnset else ObjNull + // Preserve mutability of declaration: create record with correct mutability + context.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) + initValue } - cls.instanceInitializers += initStmt - ObjVoid } else { - // We are in instance scope already: perform initialization immediately + // Not in class body: regular local/var declaration val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull - // Preserve mutability of declaration: create record with correct mutability - context.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) + context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) initValue } - } else { - // Not in class body: regular local/var declaration - val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull - context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) - initValue - } } } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index cb98dc5..1d5a1eb 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -262,6 +262,9 @@ open class Scope( fun raiseClassCastError(msg: String): Nothing = raiseError(ObjClassCastException(this, msg)) + fun raiseUnset(message: String = "property is unset (not initialized)"): Nothing = + raiseError(ObjUnsetException(this, message)) + @Suppress("unused") fun raiseSymbolNotFound(name: String): Nothing = raiseError(ObjSymbolNotDefinedException(this, "symbol is not defined: $name")) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 08aa385..3e54926 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -60,6 +60,7 @@ class Script( internal val rootScope: Scope = Scope(null).apply { ObjException.addExceptionsToContext(this) + addConst("Unset", ObjUnset) addFn("print") { for ((i, a) in args.withIndex()) { if (i > 0) print(' ' + a.toString(this).value) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt index cf89fed..d17246d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt @@ -665,6 +665,50 @@ object ObjNull : Obj() { } } +@Serializable +@SerialName("unset") +object ObjUnset : Obj() { + override suspend fun compareTo(scope: Scope, other: Obj): Int = if (other === this) 0 else -1 + override fun equals(other: Any?): Boolean = other === this + override fun toString(): String = "Unset" + + override suspend fun readField(scope: Scope, name: String): ObjRecord = scope.raiseUnset() + override suspend fun invokeInstanceMethod( + scope: Scope, + name: String, + args: Arguments, + onNotFoundResult: (suspend () -> Obj?)? + ): Obj = scope.raiseUnset() + + override suspend fun getAt(scope: Scope, index: Obj): Obj = scope.raiseUnset() + override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) = scope.raiseUnset() + override suspend fun callOn(scope: Scope): Obj = scope.raiseUnset() + override suspend fun plus(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun minus(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun negate(scope: Scope): Obj = scope.raiseUnset() + override suspend fun mul(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun div(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun mod(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun logicalNot(scope: Scope): Obj = scope.raiseUnset() + override suspend fun logicalAnd(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun logicalOr(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun operatorMatch(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun bitAnd(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun bitOr(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun bitXor(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun shl(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun shr(scope: Scope, other: Obj): Obj = scope.raiseUnset() + override suspend fun bitNot(scope: Scope): Obj = scope.raiseUnset() + + override val objClass: ObjClass by lazy { + object : ObjClass("Unset") { + override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { + return ObjUnset + } + } + } +} + /** * TODO: get rid of it. Maybe we ise some Lyng inheritance instead */ diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt index e65a5a3..e3a3f01 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt @@ -194,7 +194,9 @@ open class ObjException( "AccessException", "UnknownException", "NotFoundException", - "IllegalOperationException" + "IllegalOperationException", + "UnsetException", + "SyntaxError" )) { scope.addConst(name, getOrCreateExceptionClass(name)) } @@ -245,3 +247,6 @@ class ObjIllegalOperationException(scope: Scope, message: String = "Operation is class ObjNotFoundException(scope: Scope, message: String = "not found") : ObjException("NotFoundException", scope, message) + +class ObjUnsetException(scope: Scope, message: String = "property is unset (not initialized)") : + ObjException("UnsetException", scope, message) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt index eae9edb..9995160 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -108,7 +108,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { prop.callSetter(scope, this, newValue, decl) return } - if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() + if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (f.value.assign(scope, newValue) == null) f.value = newValue return @@ -142,7 +142,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { prop.callSetter(scope, this, newValue, declaring) return } - if (!rec.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() + if (!rec.isMutable && rec.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (rec.value.assign(scope, newValue) == null) rec.value = newValue return @@ -355,7 +355,7 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla scope, "can't assign to field $name (declared in ${decl.className})" ).raise() - if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() + if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (f.value.assign(scope, newValue) == null) f.value = newValue return } @@ -369,7 +369,7 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla scope, "can't assign to field $name (declared in ${decl?.className ?: "?"})" ).raise() - if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() + if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (f.value.assign(scope, newValue) == null) f.value = newValue return } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt index 97afbf7..eff8ae8 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -442,9 +442,9 @@ class ConstRef(private val record: ObjRecord) : ObjRef { * Reference to an object's field with optional chaining. */ class FieldRef( - private val target: ObjRef, - private val name: String, - private val isOptional: Boolean, + val target: ObjRef, + val name: String, + val isOptional: Boolean, ) : ObjRef { // 4-entry PIC for reads/writes (guarded by PerfFlags.FIELD_PIC) // Reads diff --git a/lynglib/src/commonTest/kotlin/OOTest.kt b/lynglib/src/commonTest/kotlin/OOTest.kt index 9757351..9c212cb 100644 --- a/lynglib/src/commonTest/kotlin/OOTest.kt +++ b/lynglib/src/commonTest/kotlin/OOTest.kt @@ -418,4 +418,51 @@ class OOTest { """.trimIndent()) } + + @Test + fun testLateInitValsInClasses() = runTest { + assertFails { + eval(""" + class T { + val x + } + """) + } + + assertFails { + eval("val String.late") + } + + eval(""" + // but we can "late-init" them in init block: + class OK { + val x + + init { + x = "foo" + } + } + val ok = OK() + assertEquals("foo", ok.x) + + // they can't be reassigned: + assertThrows(IllegalAssignmentException) { + ok.x = "bar" + } + + // To test access before init, we need a trick: + class AccessBefore { + val x + fun readX() = x + init { + assertEquals(x, Unset) + // if we call readX() here, x is Unset. + // Just reading it is fine, but using it should throw: + assertThrows(UnsetException) { readX() + 1 } + x = 42 + } + } + AccessBefore() + """.trimIndent()) + } } \ No newline at end of file