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:
parent
ce0fc3650d
commit
54af50d6d6
38
docs/OOP.md
38
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
|
||||
|
||||
@ -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>()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user