From eec732d11a5db12b59d660ae6d505927649e7903 Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 6 Jan 2026 01:25:01 +0100 Subject: [PATCH] implemented object expressions (anonymous classes) --- docs/OOP.md | 42 ++++++ .../kotlin/net/sergeych/lyng/Compiler.kt | 35 +++-- .../kotlin/net/sergeych/lyng/obj/ObjClass.kt | 3 + .../kotlin/net/sergeych/lyng/obj/ObjRef.kt | 23 ++-- .../kotlin/net/sergeych/lynon/LynonEncoder.kt | 1 + lynglib/src/commonTest/kotlin/OOTest.kt | 20 +++ .../commonTest/kotlin/ObjectExpressionTest.kt | 120 ++++++++++++++++++ 7 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 lynglib/src/commonTest/kotlin/ObjectExpressionTest.kt diff --git a/docs/OOP.md b/docs/OOP.md index ba97362..c29e0bd 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -71,6 +71,48 @@ object DefaultLogger : Logger("Default") { } ``` +## Object Expressions + +Object expressions allow you to create an instance of an anonymous class. This is useful when you need to provide a one-off implementation of an interface or inherit from a class without declaring a new named subclass. + +```lyng +val worker = object : Runnable { + override fun run() { + println("Working...") + } +} +``` + +Object expressions can implement multiple interfaces and inherit from one class: + +```lyng +val x = object : Base(arg1), Interface1, Interface2 { + val property = 42 + override fun method() = property * 2 +} +``` + +### Scoping and `this@object` + +Object expressions capture their lexical scope, meaning they can access local variables and members of the outer class. When `this` is rebound (for example, inside an `apply` block), you can use the reserved alias `this@object` to refer to the innermost anonymous object instance. + +```lyng +val handler = object { + fun process() { + this@object.apply { + // here 'this' is rebound to the map/context + // but we can still access the anonymous object via this@object + println("Processing in " + this@object) + } + } +} +``` + +### Serialization and Identity + +- **Serialization**: Anonymous objects are **not serializable**. Attempting to encode an anonymous object via `Lynon` will throw a `SerializationException`. This is because their class definition is transient and cannot be safely restored in a different session or process. +- **Type Identity**: Every object expression creates a unique anonymous class. Two identical object expressions will result in two different classes with distinct type identities. + ## Properties Properties allow you to define member accessors that look like fields but execute code when read or written. Unlike regular fields, properties in Lyng do **not** have automatic backing fields; they are pure accessors. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 9a71f2c..ccd201e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -95,6 +95,11 @@ class Compiler( } } + private var anonCounter = 0 + private fun generateAnonName(pos: Pos): String { + return "${"$"}${"Anon"}_${pos.line+1}_${pos.column}_${++anonCounter}" + } + private fun pushPendingDocToken(t: Token) { val s = stripCommentLexeme(t.value) if (pendingDocStart == null) pendingDocStart = t.pos @@ -248,7 +253,7 @@ class Compiler( when (t.type) { Token.Type.RBRACE, Token.Type.EOF, Token.Type.SEMICOLON -> {} else -> - throw ScriptError(t.pos, "unexpeced `${t.value}` here") + throw ScriptError(t.pos, "unexpected `${t.value}` here") } break } @@ -1248,6 +1253,8 @@ class Compiler( } } + Token.Type.OBJECT -> StatementRef(parseObjectDeclaration()) + else -> null } } @@ -1860,8 +1867,12 @@ class Compiler( } private suspend fun parseObjectDeclaration(): Statement { - val nameToken = cc.requireToken(Token.Type.ID) - val startPos = pendingDeclStart ?: nameToken.pos + val next = cc.peekNextNonWhitespace() + val nameToken = if (next.type == Token.Type.ID) cc.requireToken(Token.Type.ID) else null + + val startPos = pendingDeclStart ?: nameToken?.pos ?: cc.current().pos + val className = nameToken?.value ?: generateAnonName(startPos) + val doc = pendingDeclDoc ?: consumePendingDoc() pendingDeclDoc = null pendingDeclStart = null @@ -1887,11 +1898,11 @@ class Compiler( // Robust body detection var classBodyRange: MiniRange? = null - val bodyInit: Statement? = run { + val bodyInit: Statement? = inCodeContext(CodeContext.ClassBody(className)) { val saved = cc.savePos() - val next = cc.nextNonWhitespace() - if (next.type == Token.Type.LBRACE) { - val bodyStart = next.pos + val nextBody = cc.nextNonWhitespace() + if (nextBody.type == Token.Type.LBRACE) { + val bodyStart = nextBody.pos val st = withLocalNames(emptySet()) { parseScript() } @@ -1906,15 +1917,15 @@ class Compiler( } val initScope = popInitScope() - val className = nameToken.value return statement(startPos) { context -> val parentClasses = baseSpecs.map { baseSpec -> - val rec = context[baseSpec.name] ?: throw ScriptError(nameToken.pos, "unknown base class: ${baseSpec.name}") - (rec.value as? ObjClass) ?: throw ScriptError(nameToken.pos, "${baseSpec.name} is not a class") + val rec = context[baseSpec.name] ?: throw ScriptError(startPos, "unknown base class: ${baseSpec.name}") + (rec.value as? ObjClass) ?: throw ScriptError(startPos, "${baseSpec.name} is not a class") } val newClass = ObjInstanceClass(className, *parentClasses.toTypedArray()) + newClass.isAnonymous = nameToken == null for (i in parentClasses.indices) { val argsList = baseSpecs[i].args // In object, we evaluate parent args once at creation time @@ -1924,12 +1935,14 @@ class Compiler( val classScope = context.createChildScope(newThisObj = newClass) classScope.currentClassCtx = newClass newClass.classScope = classScope + classScope.addConst("object", newClass) bodyInit?.execute(classScope) // Create instance (singleton) val instance = newClass.callOn(context.createChildScope(Arguments.EMPTY)) - context.addItem(className, false, instance) + if (nameToken != null) + context.addItem(className, false, instance) instance } } 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 13f362a..563f9b0 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt @@ -30,6 +30,7 @@ private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ } val ObjClassType by lazy { ObjClass("Class").apply { + addProperty("className", getter = { thisAs().classNameObj }) addFnDoc( name = "name", doc = "Simple name of this class (without package).", @@ -91,6 +92,8 @@ open class ObjClass( vararg parents: ObjClass, ) : Obj() { + var isAnonymous: Boolean = false + var isAbstract: Boolean = false // Stable identity and simple structural version for PICs 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 4f0e2b8..2745f2d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -321,16 +321,23 @@ class CastRef( /** Qualified `this@Type`: resolves to a view of current `this` starting dispatch from the ancestor Type. */ class QualifiedThisRef(private val typeName: String, private val atPos: Pos) : ObjRef { override suspend fun get(scope: Scope): ObjRecord { - val thisObj = scope.thisObj val t = scope[typeName]?.value as? ObjClass ?: scope.raiseError("unknown type $typeName") - val inst = (thisObj as? ObjInstance) - ?: scope.raiseClassCastError("this is not an instance") - if (!inst.objClass.allParentsSet.contains(t) && inst.objClass !== t) - scope.raiseClassCastError( - "Qualifier ${'$'}{t.className} is not an ancestor of ${'$'}{inst.objClass.className} (order: ${'$'}{inst.objClass.renderLinearization(true)})" - ) - return ObjQualifiedView(inst, t).asReadonly + + var s: Scope? = scope + while (s != null) { + val inst = s.thisObj as? ObjInstance + if (inst != null) { + if (inst.objClass === t || inst.objClass.allParentsSet.contains(t)) { + return ObjQualifiedView(inst, t).asReadonly + } + } + s = s.parent + } + + scope.raiseClassCastError( + "No instance of type ${t.className} found in the scope chain" + ) } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt index 225add5..6ad18be 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonEncoder.kt @@ -101,6 +101,7 @@ open class LynonEncoder(val bout: BitOutput, val settings: LynonSettings = Lynon * Caching is used automatically. */ suspend fun encodeAny(scope: Scope, obj: Obj) { + if (obj.objClass.isAnonymous) scope.raiseError("Cannot serialize anonymous object") encodeCached(obj) { val type = putTypeRecord(scope, obj, obj.lynonType()) obj.serialize(scope, this, type) diff --git a/lynglib/src/commonTest/kotlin/OOTest.kt b/lynglib/src/commonTest/kotlin/OOTest.kt index b9ed415..3ee062e 100644 --- a/lynglib/src/commonTest/kotlin/OOTest.kt +++ b/lynglib/src/commonTest/kotlin/OOTest.kt @@ -711,4 +711,24 @@ class OOTest { """.trimIndent() ) } + @Test + fun testBasicObjectExpression() = runTest { + eval(""" + val x = object { val y = 1 } + assertEquals(1, x.y) + + class Base(v) { + val value = v + fun squares() = value * value + } + + val y = object : Base(2) { + override val value = 5 + } + + assertEquals(25, y.squares()) + + """.trimIndent()) + } + } \ No newline at end of file diff --git a/lynglib/src/commonTest/kotlin/ObjectExpressionTest.kt b/lynglib/src/commonTest/kotlin/ObjectExpressionTest.kt new file mode 100644 index 0000000..c2b0cef --- /dev/null +++ b/lynglib/src/commonTest/kotlin/ObjectExpressionTest.kt @@ -0,0 +1,120 @@ +package net.sergeych.lyng + +import kotlinx.coroutines.test.runTest +import net.sergeych.lynon.lynonEncodeAny +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class ObjectExpressionTest { + + @Test + fun testBasicObjectExpression() = runTest { + eval(""" + val x = object { val y = 1 } + assertEquals(1, x.y) + """.trimIndent()) + } + + @Test + fun testInheritanceWithArgs() = runTest { + eval(""" + class Base(x) { + val value = x + val squares = x * x + } + + val y = object : Base(5) { + val z = value + 1 + } + + assertEquals(5, y.value) + assertEquals(25, y.squares) + assertEquals(6, y.z) + """.trimIndent()) + } + + @Test + fun testMultipleInheritance() = runTest { + eval(""" + interface A { fun a() = "A" } + interface B { fun b() = "B" } + + val x = object : A, B { + fun c() = a() + b() + } + + assertEquals("AB", x.c()) + """.trimIndent()) + } + + @Test + fun testScopeCapture() = runTest { + eval(""" + fun createCounter(start) { + var count = start + object { + fun next() { + val res = count + count = count + 1 + res + } + } + } + + val c = createCounter(10) + assertEquals(10, c.next()) + assertEquals(11, c.next()) + """.trimIndent()) + } + + @Test + fun testThisObjectAlias() = runTest { + eval(""" + val x = object { + val value = 42 + fun self() = this@object + fun getValue() = this@object.value + } + + assertEquals(42, x.getValue()) + // assert(x === x.self()) // Lyng might not have === for identity yet, checking if it compiles and runs + """.trimIndent()) + } + + @Test + fun testSerializationRejection() = runTest { + val scope = Script.newScope() + val obj = scope.eval("object { val x = 1 }") + assertFailsWith { + lynonEncodeAny(scope, obj) + } + } + + @Test + fun testQualifiedThis() = runTest { + eval(""" + class Outer { + val value = 1 + fun getObj() { + object { + fun getOuterValue() = this@Outer.value + } + } + } + + val o = Outer() + val x = o.getObj() + assertEquals(1, x.getOuterValue()) + """.trimIndent()) + } + + @Test + fun testDiagnosticName() = runTest { + // This is harder to test directly, but we can check if it has a class and if that class name looks "anonymous" + eval(""" + val x = object { } + val name = x::class.className + assert(name.startsWith("${'$'}Anon_")) + """.trimIndent()) + } +}