implemented object expressions (anonymous classes)
This commit is contained in:
parent
8611543623
commit
eec732d11a
42
docs/OOP.md
42
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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ObjClass>().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
|
||||
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
120
lynglib/src/commonTest/kotlin/ObjectExpressionTest.kt
Normal file
120
lynglib/src/commonTest/kotlin/ObjectExpressionTest.kt
Normal file
@ -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<Exception> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user