Implement support for late-initialized val fields in classes. Added ObjUnset, UnsetException, compile-time initialization checks, and write-once enforcement for val fields.

This commit is contained in:
Sergey Chernov 2026-01-03 22:05:29 +01:00
parent ce0fc3650d
commit 54af50d6d6
10 changed files with 206 additions and 36 deletions

View File

@ -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

View File

@ -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<String, Pos>()
}
}

View File

@ -138,9 +138,16 @@ class Compiler(
private var lastParsedBlockRange: MiniRange? = null
private suspend fun <T> 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
}
}
}
}

View File

@ -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"))

View File

@ -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)

View File

@ -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
*/

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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())
}
}