Preserve static return types and initialize singleton static methods

This commit is contained in:
sergeych committed 2026-10-21 00:01:11 +04:00
1 parent 04c9343a9d
commit c34371b784
7 files changed
+162 -10

No files matched your search

+1
View File
@@ -126,6 +126,7 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
- extension functions: `fun Type.name(...) { ... }`. - extension functions: `fun Type.name(...) { ... }`.
- context-aware extension functions: `context(Tag) fun String.unaryPlus() { this@Tag.addText(this) }`. - context-aware extension functions: `context(Tag) fun String.unaryPlus() { this@Tag.addText(this) }`.
- named singleton `object` declarations can be extension receivers too: `fun Config.describe(...) { ... }`, `val Config.tag get() = ...`. - named singleton `object` declarations can be extension receivers too: `fun Config.describe(...) { ... }`, `val Config.tag get() = ...`.
- Static class/object methods preserve declared or inferable return types at call sites, including chained member access and generic return types inferred from arguments. For example, `static fun of<T>(v: T): Box<T> = Box(v)` lets `Box.of("abc").value.length` retain the `String` member type.
- static extension functions are callable on the type object: `static fun List<T>.fill(...)` -> `List.fill(...)`. - static extension functions are callable on the type object: `static fun List<T>.fill(...)` -> `List.fill(...)`.
- delegated callable: `fun f(...) by delegate`. - delegated callable: `fun f(...) by delegate`.
- Type aliases: - Type aliases:
+65
View File
@@ -0,0 +1,65 @@
### Ticket 2 — Static member calls: declared return type is not propagated, receiver becomes `Object`
**Summary.** The **invocation** of a `static` member (class or `object` singleton) loses its declared return type at the call site; the result is treated as a general receiver, so every type-specific member access fails to compile. The runtime value is correct — this is purely a static-typing defect. Constructor calls, instance-method calls and top-level function calls all preserve the declared type correctly, so the inconsistency is specific to static members.
**Repro**
```lyng
class S(x) {
static fun make(y): S = S(y) // return type is explicitly declared
fun hi(): String = "hi"
}
val a: S = S.make(1) // OK — annotation rescues it
println(a.hi())
val b = S.make(2) // b is S == true at runtime
println(b.hi()) // Error: member access requires compile-time receiver type: hi
println(S.make(1).hi())// Error: same, chained call
println((S.make(3) as S).hi()) // OK — explicit cast rescues it
fun need(v: S) = v.hi(); need(S.make(4)) // OK — typed argument position rescues it
```
Not limited to self-returning factories — any declared return type is dropped:
```lyng
class K(x) { static fun text(y): String = "t"; static fun num(y): Int = y }
val s = K.text(1); println(s.length) // Error: ... compile-time receiver type: length
val n = K.num(1); println(n + 1) // same class of failure
object O { static fun text(y): String = "o" }
val o = O.text(1); println(o.length) // Error: ... on O.text result
class B(x) { static fun noRet(y) = B(y) } // untyped static: also general
class Box<T>(val value: T) { static fun of<T>(v: T): Box<T> = Box(v) }
val bx = Box.of(42); println(bx.value) // Error; val bx2: Box<Int> = Box.of(43) works
```
Controls that **do** work correctly: `val d = K8(9); d.hi()` (constructor), `val c = S(3).again(4); c.hi()` (instance method), `val t = topLevelMake(1); t.x` (top-level function).
**Expected.** `StaticClass.member(args)` must produce the declared return type as the static compile-time receiver type, exactly like instance and top-level calls. Only genuinely untyped statics (`fun` without a return type and uninferrable bodies) should fall back to `Object`/`Obj`.
**Suggested tests.** Matrix over `{constructor | instance method | top-level fun | static fun} × {declared ret type | inferred | omitted}` asserting that `.length`, arithmetic, `is T`-then-member, chained calls and generic instantiation behave identically for statics as for the other three; plus `object` singletons and generic statics.
## Resolution
Valid: static method signatures were excluded from the compiler's member type
metadata, both before body compilation and when inferred returns were recorded.
They now use the same callable signature and generic substitution path as
instance methods. Explicit annotations, inferred expression returns, primitive
returns, chained calls, and inferred/explicit generic arguments are covered by
`StaticReturnTypeRegressionTest`.
The singleton example additionally exposed runtime bugs once typing was
corrected: object static initializers were discarded, and a static call on an
object was sent to instance-method dispatch. Object declarations now preserve
and execute their static initialization statements before constructing the
singleton instance.
The compiler now retains its class-scope call decision for bytecode generation;
singleton static calls use the object's class and the existing class-scope
access opcode. Instance methods retain their dispatch path. Tests also cover
static calls with a trailing lambda.
Before the fix, all 5 original regression cases failed with missing compile-time
receiver types. The initial type-only correction passed 4, with the singleton
case then failing at runtime; dispatch is corrected in this same report.
Validation: all 6 focused JVM regression tests passed, followed by all 1,040
`:lynglib:jvmTest` tests (benchmarks excluded by the repository default).
`git diff --check` also passed.
@@ -88,6 +88,9 @@ internal suspend fun executeClassDecl(
classScope.addConst("object", newClass) classScope.addConst("object", newClass)
spec.bodyInit?.let { executeBytecodeWithSeed(classScope, it, "object body init") } spec.bodyInit?.let { executeBytecodeWithSeed(classScope, it, "object body init") }
for (statement in spec.initScope) {
executeBytecodeWithSeed(classScope, statement, "object static init")
}
val instance = newClass.callOn(scope.createChildScope(Arguments.EMPTY)) val instance = newClass.callOn(scope.createChildScope(Arguments.EMPTY))
if (spec.declaredName != null) { if (spec.declaredName != null) {
@@ -3121,7 +3121,7 @@ class Compiler(
val field = FieldRef(left, next.value, isOptional) val field = FieldRef(left, next.value, isOptional)
operand = CallRef(field, args, tailBlock, isOptional, memberCallTypeArgs) operand = CallRef(field, args, tailBlock, isOptional, memberCallTypeArgs)
} else { } else {
operand = MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs) operand = MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs, classScopeCall = true)
} }
} else { } else {
// instance method call // instance method call
@@ -3238,14 +3238,14 @@ class Compiler(
resolutionSink?.referenceReflection(name.value, next.pos) resolutionSink?.referenceReflection(name.value, next.pos)
} }
} }
MethodCallRef(left, next.value, args, true, isOptional) MethodCallRef(left, next.value, args, true, isOptional, classScopeCall = shouldTreatAsClassScopeCall(left, next.value))
} else { } else {
val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, true) val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, true)
if (unionCall != null) { if (unionCall != null) {
unionCall unionCall
} else { } else {
enforceReceiverTypeForMember(left, next.value, next.pos) enforceReceiverTypeForMember(left, next.value, next.pos)
MethodCallRef(left, next.value, args, true, isOptional) MethodCallRef(left, next.value, args, true, isOptional, classScopeCall = shouldTreatAsClassScopeCall(left, next.value))
} }
} }
is QualifiedThisRef -> is QualifiedThisRef ->
@@ -3265,7 +3265,7 @@ class Compiler(
unionCall unionCall
} else { } else {
enforceReceiverTypeForMember(left, next.value, next.pos) enforceReceiverTypeForMember(left, next.value, next.pos)
MethodCallRef(left, next.value, args, true, isOptional) MethodCallRef(left, next.value, args, true, isOptional, classScopeCall = shouldTreatAsClassScopeCall(left, next.value))
} }
} }
} }
@@ -8686,6 +8686,7 @@ class Compiler(
val initScope = popInitScope() val initScope = popInitScope()
val wrappedBodyInit = bodyInit?.let { wrapClassBodyBytecode(it, "object@$className") } val wrappedBodyInit = bodyInit?.let { wrapClassBodyBytecode(it, "object@$className") }
val wrappedInitScope = initScope.map { wrapClassBodyBytecode(it, "objectInit@$className") }
val spec = ClassDeclSpec( val spec = ClassDeclSpec(
declaredName = declaredName, declaredName = declaredName,
@@ -8701,7 +8702,7 @@ class Compiler(
constructorArgs = null, constructorArgs = null,
constructorFieldIds = null, constructorFieldIds = null,
bodyInit = wrappedBodyInit, bodyInit = wrappedBodyInit,
initScope = emptyList() initScope = wrappedInitScope
) )
return ClassDeclStatement(spec) return ClassDeclStatement(spec)
} }
@@ -10071,7 +10072,7 @@ class Compiler(
pendingTypeParamStack.removeLast() pendingTypeParamStack.removeLast()
} }
if (parentContext is CodeContext.ClassBody && !isStatic && extTypeName == null) { if (parentContext is CodeContext.ClassBody && extTypeName == null) {
classMemberTypeDeclByName.getOrPut(parentContext.name) { mutableMapOf() }[name] = TypeDecl.Function( classMemberTypeDeclByName.getOrPut(parentContext.name) { mutableMapOf() }[name] = TypeDecl.Function(
receiver = receiverTypeDecl, receiver = receiverTypeDecl,
contextReceivers = contextReceiverTypeDecls, contextReceivers = contextReceiverTypeDecls,
@@ -10245,7 +10246,7 @@ class Compiler(
val inferredReturnDecl = returnTypeDecl ?: inferReturnTypeDeclFromStatement(rawFnStatements) val inferredReturnDecl = returnTypeDecl ?: inferReturnTypeDeclFromStatement(rawFnStatements)
val inferredReturnClass = inferredReturnDecl?.let { resolveTypeDeclObjClass(it) } val inferredReturnClass = inferredReturnDecl?.let { resolveTypeDeclObjClass(it) }
?: inferReturnClassFromStatement(rawFnStatements) ?: inferReturnClassFromStatement(rawFnStatements)
if (parentContext is CodeContext.ClassBody && !isStatic && extTypeName == null) { if (parentContext is CodeContext.ClassBody && extTypeName == null) {
val ownerClassName = parentContext.name val ownerClassName = parentContext.name
run { run {
val memberTypeDecl = TypeDecl.Function( val memberTypeDecl = TypeDecl.Function(
@@ -4876,11 +4876,16 @@ class BytecodeCompiler(
val receiverClass = resolveReceiverClass(ref.receiver) ?: ObjDynamic.type val receiverClass = resolveReceiverClass(ref.receiver) ?: ObjDynamic.type
val receiver = compileRefWithFallback(ref.receiver, null, refPosOrCurrent(ref.receiver)) ?: return null val receiver = compileRefWithFallback(ref.receiver, null, refPosOrCurrent(ref.receiver)) ?: return null
val dst = allocSlot() val dst = allocSlot()
if (shouldUseClassScopeReceiver(ref.receiver, receiver, receiverClass)) { val classReceiver = shouldUseClassScopeReceiver(ref.receiver, receiver, receiverClass)
if (ref.classScopeCall || classReceiver) {
// Singleton objects carry their static members on the instance's class.
val classSlot = if (classReceiver) receiver.slot else allocSlot().also {
builder.emit(Opcode.GET_OBJ_CLASS, receiver.slot, it)
}
val nameId = builder.addConst(BytecodeConst.StringVal(ref.name)) val nameId = builder.addConst(BytecodeConst.StringVal(ref.name))
val memberSlot = allocSlot() val memberSlot = allocSlot()
if (!ref.isOptional) { if (!ref.isOptional) {
builder.emit(Opcode.GET_CLASS_SCOPE, receiver.slot, nameId, memberSlot) builder.emit(Opcode.GET_CLASS_SCOPE, classSlot, nameId, memberSlot)
} else { } else {
val nullSlot = allocSlot() val nullSlot = allocSlot()
builder.emit(Opcode.CONST_NULL, nullSlot) builder.emit(Opcode.CONST_NULL, nullSlot)
@@ -4892,7 +4897,7 @@ class BytecodeCompiler(
Opcode.JMP_IF_TRUE, Opcode.JMP_IF_TRUE,
listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel)) listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel))
) )
builder.emit(Opcode.GET_CLASS_SCOPE, receiver.slot, nameId, memberSlot) builder.emit(Opcode.GET_CLASS_SCOPE, classSlot, nameId, memberSlot)
builder.emit(Opcode.JMP, listOf(CmdBuilder.Operand.LabelRef(endLabel))) builder.emit(Opcode.JMP, listOf(CmdBuilder.Operand.LabelRef(endLabel)))
builder.mark(nullLabel) builder.mark(nullLabel)
builder.emit(Opcode.CONST_NULL, memberSlot) builder.emit(Opcode.CONST_NULL, memberSlot)
@@ -484,6 +484,7 @@ class MethodCallRef(
internal val tailBlock: Boolean, internal val tailBlock: Boolean,
internal val isOptional: Boolean, internal val isOptional: Boolean,
internal val explicitTypeArgs: List<TypeDecl>? = null, internal val explicitTypeArgs: List<TypeDecl>? = null,
internal val classScopeCall: Boolean = false,
) : ObjRef { ) : ObjRef {
// 4-entry PIC for method invocations (guarded by PerfFlags.METHOD_PIC) // 4-entry PIC for method invocations (guarded by PerfFlags.METHOD_PIC)
private var mKey1: Long = 0L; private var mVer1: Int = -1; private var mInvoker1: (suspend (Obj, Scope, Arguments) -> Obj)? = null private var mKey1: Long = 0L; private var mVer1: Int = -1; private var mInvoker1: (suspend (Obj, Scope, Arguments) -> Obj)? = null
@@ -0,0 +1,76 @@
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.eval
import kotlin.test.Test
import kotlin.test.assertEquals
class StaticReturnTypeRegressionTest {
@Test
fun declaredFactoryReturnSupportsInferredVariablesAndChains() = runTest {
assertEquals("hi hi hi", eval("""
class Factory(x) {
static fun make(y): Factory = Factory(y)
fun hi(): String = "hi"
}
val a: Factory = Factory.make(1)
val b = Factory.make(2)
a.hi() + " " + b.hi() + " " + Factory.make(3).hi()
""".trimIndent()).toString())
}
@Test
fun declaredScalarReturnsSupportMembersAndArithmetic() = runTest {
assertEquals("4", eval("""
class Scalars {
static fun text(y): String = "abc"
static fun num(y: Int): Int = y
}
val s = Scalars.text(1)
val n = Scalars.num(1)
s.length + n
""".trimIndent()).toString())
}
@Test
fun singletonStaticReturnSupportsMembers() = runTest {
assertEquals("3", eval("""
object Singleton { static fun text(): String = "abc" }
val text = Singleton.text()
text.length
""".trimIndent()).toString())
}
@Test
fun singletonStaticTailBlockAndInstanceMethodsUseTheirOwnDispatch() = runTest {
assertEquals("6", eval("""
object Singleton {
static fun text(block: ()->String): String = block()
fun instanceText(): String = "abc"
}
Singleton.text { "abc" }.length + Singleton.instanceText().length
""".trimIndent()).toString())
}
@Test
fun inferredStaticReturnMatchesInstanceAndTopLevelFunctions() = runTest {
assertEquals("9", eval("""
class Factory {
static fun text() = "abc"
fun instanceText() = "abc"
}
fun topText() = "abc"
Factory.text().length + Factory().instanceText().length + topText().length
""".trimIndent()).toString())
}
@Test
fun genericStaticReturnSubstitutesArgumentType() = runTest {
assertEquals("7", eval("""
class Box<T>(val value: T) {
static fun of<T>(v: T): Box<T> = Box(v)
}
val box = Box.of("abc")
val explicit = Box.of<String>("abcd")
box.value.length + explicit.value.length
""".trimIndent()).toString())
}
}