Define type expression checks for unions

This commit is contained in:
Sergey Chernov 2026-02-05 20:14:09 +03:00
parent c31343a040
commit 40de53f688
7 changed files with 300 additions and 37 deletions

View File

@ -11,6 +11,7 @@
- Nullability is Kotlin-style: `T` non-null, `T?` nullable, `!!` asserts non-null. - Nullability is Kotlin-style: `T` non-null, `T?` nullable, `!!` asserts non-null.
- `void` is a singleton of class `Void` (syntax sugar for return type). - `void` is a singleton of class `Void` (syntax sugar for return type).
- Object members are always allowed even on unknown types; non-Object members require explicit casts. Remove `inspect` from Object and use `toInspectString()` instead. - Object members are always allowed even on unknown types; non-Object members require explicit casts. Remove `inspect` from Object and use `toInspectString()` instead.
- Type expression checks: `x is T` is value instance check; `T1 is T2` is type-subset; `A in T` means `A` is subset of `T`; `==` is structural type equality.
- Do not reintroduce bytecode fallback opcodes (e.g., `GET_NAME`, `EVAL_*`, `CALL_FALLBACK`) or runtime name-resolution fallbacks; all symbol resolution must stay compile-time only. - Do not reintroduce bytecode fallback opcodes (e.g., `GET_NAME`, `EVAL_*`, `CALL_FALLBACK`) or runtime name-resolution fallbacks; all symbol resolution must stay compile-time only.
## Bytecode frame-first migration plan ## Bytecode frame-first migration plan

View File

@ -3706,7 +3706,7 @@ class Compiler(
typeParams: List<TypeDecl.TypeParam> typeParams: List<TypeDecl.TypeParam>
) { ) {
if (typeParams.isEmpty()) return if (typeParams.isEmpty()) return
val inferred = mutableMapOf<String, ObjClass>() val inferred = mutableMapOf<String, TypeDecl>()
for (param in argsDeclaration.params) { for (param in argsDeclaration.params) {
val rec = context.getLocalRecordDirect(param.name) ?: continue val rec = context.getLocalRecordDirect(param.name) ?: continue
val value = rec.value val value = rec.value
@ -3715,13 +3715,17 @@ class Compiler(
} }
} }
for (tp in typeParams) { for (tp in typeParams) {
val cls = inferred[tp.name] val inferredType = inferred[tp.name] ?: tp.defaultType ?: TypeDecl.TypeAny
?: tp.defaultType?.let { resolveTypeDeclObjClass(it) } val normalized = normalizeRuntimeTypeDecl(inferredType)
?: Obj.rootObjectType val cls = resolveTypeDeclObjClass(normalized)
context.addConst(tp.name, cls) if (cls != null && !normalized.isNullable && normalized !is TypeDecl.Union && normalized !is TypeDecl.Intersection) {
context.addConst(tp.name, cls)
} else {
context.addConst(tp.name, net.sergeych.lyng.obj.ObjTypeExpr(normalized))
}
val bound = tp.bound ?: continue val bound = tp.bound ?: continue
if (!typeParamBoundSatisfied(cls, bound)) { if (!typeDeclSatisfiesBound(normalized, bound)) {
context.raiseError("type argument ${cls.className} does not satisfy bound ${typeDeclName(bound)}") context.raiseError("type argument ${typeDeclName(normalized)} does not satisfy bound ${typeDeclName(bound)}")
} }
} }
} }
@ -3729,42 +3733,69 @@ class Compiler(
private fun collectRuntimeTypeVarBindings( private fun collectRuntimeTypeVarBindings(
paramType: TypeDecl, paramType: TypeDecl,
value: Obj, value: Obj,
inferred: MutableMap<String, ObjClass> inferred: MutableMap<String, TypeDecl>
) { ) {
when (paramType) { when (paramType) {
is TypeDecl.TypeVar -> { is TypeDecl.TypeVar -> {
if (value !== ObjNull) { if (value !== ObjNull) {
inferred[paramType.name] = value.objClass inferred[paramType.name] = inferRuntimeTypeDecl(value)
} }
} }
is TypeDecl.Generic -> { is TypeDecl.Generic -> {
val base = paramType.name.substringAfterLast('.') val base = paramType.name.substringAfterLast('.')
val arg = paramType.args.firstOrNull() val arg = paramType.args.firstOrNull()
if (base == "List" && arg is TypeDecl.TypeVar && value is ObjList) { if (base == "List" && arg is TypeDecl.TypeVar && value is ObjList) {
val elementClass = inferListElementClass(value) val elementType = inferListElementTypeDecl(value)
inferred[arg.name] = elementClass inferred[arg.name] = elementType
} }
} }
else -> {} else -> {}
} }
} }
private fun inferListElementClass(list: ObjList): ObjClass { private fun inferRuntimeTypeDecl(value: Obj): TypeDecl {
var elemClass: ObjClass? = null return when (value) {
is ObjInt -> TypeDecl.Simple("Int", false)
is ObjReal -> TypeDecl.Simple("Real", false)
is ObjString -> TypeDecl.Simple("String", false)
is ObjBool -> TypeDecl.Simple("Bool", false)
is ObjChar -> TypeDecl.Simple("Char", false)
is ObjNull -> TypeDecl.TypeNullableAny
is ObjList -> TypeDecl.Generic("List", listOf(inferListElementTypeDecl(value)), false)
is ObjMap -> TypeDecl.Generic("Map", listOf(TypeDecl.TypeAny, TypeDecl.TypeAny), false)
is ObjClass -> TypeDecl.Simple(value.className, false)
else -> TypeDecl.Simple(value.objClass.className, false)
}
}
private fun inferListElementTypeDecl(list: ObjList): TypeDecl {
var nullable = false
val options = mutableListOf<TypeDecl>()
val seen = mutableSetOf<String>()
for (elem in list.list) { for (elem in list.list) {
if (elem === ObjNull) { if (elem === ObjNull) {
elemClass = Obj.rootObjectType nullable = true
break continue
}
val cls = elem.objClass
if (elemClass == null) {
elemClass = cls
} else if (elemClass != cls) {
elemClass = Obj.rootObjectType
break
} }
val elemType = inferRuntimeTypeDecl(elem)
val base = stripNullable(elemType).first
val key = typeDeclKey(base)
if (seen.add(key)) options += base
}
val base = when {
options.isEmpty() -> TypeDecl.TypeAny
options.size == 1 -> options[0]
else -> TypeDecl.Union(options, nullable = false)
}
return if (nullable) makeTypeDeclNullable(base) else base
}
private fun normalizeRuntimeTypeDecl(type: TypeDecl): TypeDecl {
return when (type) {
is TypeDecl.Union -> TypeDecl.Union(type.options.distinctBy { typeDeclKey(it) }, type.isNullable)
is TypeDecl.Intersection -> TypeDecl.Intersection(type.options.distinctBy { typeDeclKey(it) }, type.isNullable)
else -> type
} }
return elemClass ?: Obj.rootObjectType
} }
private fun resolveLocalTypeRef(name: String, pos: Pos): ObjRef? { private fun resolveLocalTypeRef(name: String, pos: Pos): ObjRef? {

View File

@ -207,9 +207,14 @@ class CmdCheckIs(internal val objSlot: Int, internal val typeSlot: Int, internal
override suspend fun perform(frame: CmdFrame) { override suspend fun perform(frame: CmdFrame) {
val obj = frame.slotToObj(objSlot) val obj = frame.slotToObj(objSlot)
val typeObj = frame.slotToObj(typeSlot) val typeObj = frame.slotToObj(typeSlot)
val result = when (typeObj) { val result = when {
is ObjTypeExpr -> matchesTypeDecl(frame.ensureScope(), obj, typeObj.typeDecl) (obj is ObjTypeExpr || obj is ObjClass) && (typeObj is ObjTypeExpr || typeObj is ObjClass) -> {
is ObjClass -> obj.isInstanceOf(typeObj) val leftDecl = typeDeclFromObj(frame.ensureScope(), obj) ?: return frame.setBool(dst, false)
val rightDecl = typeDeclFromObj(frame.ensureScope(), typeObj) ?: return frame.setBool(dst, false)
typeDeclIsSubtype(frame.ensureScope(), leftDecl, rightDecl)
}
typeObj is ObjTypeExpr -> matchesTypeDecl(frame.ensureScope(), obj, typeObj.typeDecl)
typeObj is ObjClass -> obj.isInstanceOf(typeObj)
else -> false else -> false
} }
frame.setBool(dst, result) frame.setBool(dst, result)
@ -1020,7 +1025,22 @@ class CmdModObj(internal val a: Int, internal val b: Int, internal val dst: Int)
class CmdContainsObj(internal val target: Int, internal val value: Int, internal val dst: Int) : Cmd() { class CmdContainsObj(internal val target: Int, internal val value: Int, internal val dst: Int) : Cmd() {
override suspend fun perform(frame: CmdFrame) { override suspend fun perform(frame: CmdFrame) {
frame.setBool(dst, frame.slotToObj(target).contains(frame.ensureScope(), frame.slotToObj(value))) val targetObj = frame.slotToObj(target)
val valueObj = frame.slotToObj(value)
val result = if ((targetObj is ObjTypeExpr || targetObj is ObjClass) &&
(valueObj is ObjTypeExpr || valueObj is ObjClass)
) {
val leftDecl = typeDeclFromObj(frame.ensureScope(), valueObj)
val rightDecl = typeDeclFromObj(frame.ensureScope(), targetObj)
if (leftDecl != null && rightDecl != null) {
typeDeclIsSubtype(frame.ensureScope(), leftDecl, rightDecl)
} else {
false
}
} else {
targetObj.contains(frame.ensureScope(), valueObj)
}
frame.setBool(dst, result)
return return
} }
} }

View File

@ -152,9 +152,23 @@ class BinaryOpRef(internal val op: BinOp, internal val left: ObjRef, internal va
val a = left.evalValue(scope) val a = left.evalValue(scope)
val b = right.evalValue(scope) val b = right.evalValue(scope)
if (op == BinOp.IS || op == BinOp.NOTIS) { if (op == BinOp.IS || op == BinOp.NOTIS) {
if (b is ObjTypeExpr) { val result = when {
val result = matchesTypeDecl(scope, a, b.typeDecl) (a is ObjTypeExpr || a is ObjClass) && (b is ObjTypeExpr || b is ObjClass) -> {
return if (op == BinOp.NOTIS) ObjBool(!result) else ObjBool(result) val leftDecl = typeDeclFromObj(scope, a) ?: return ObjBool(false)
val rightDecl = typeDeclFromObj(scope, b) ?: return ObjBool(false)
typeDeclIsSubtype(scope, leftDecl, rightDecl)
}
b is ObjTypeExpr -> matchesTypeDecl(scope, a, b.typeDecl)
else -> a.isInstanceOf(b)
}
return if (op == BinOp.NOTIS) ObjBool(!result) else ObjBool(result)
}
if (op == BinOp.IN || op == BinOp.NOTIN) {
if ((b is ObjTypeExpr || b is ObjClass) && (a is ObjTypeExpr || a is ObjClass)) {
val leftDecl = typeDeclFromObj(scope, a) ?: return ObjBool(op == BinOp.NOTIN)
val rightDecl = typeDeclFromObj(scope, b) ?: return ObjBool(op == BinOp.NOTIN)
val result = typeDeclIsSubtype(scope, leftDecl, rightDecl)
return if (op == BinOp.NOTIN) ObjBool(!result) else ObjBool(result)
} }
} }

View File

@ -22,7 +22,20 @@ import net.sergeych.lyng.TypeDecl
/** /**
* Runtime wrapper for a type expression (including unions/intersections) used by `is` checks. * Runtime wrapper for a type expression (including unions/intersections) used by `is` checks.
*/ */
class ObjTypeExpr(val typeDecl: TypeDecl) : Obj() class ObjTypeExpr(val typeDecl: TypeDecl) : Obj() {
override suspend fun equals(scope: Scope, other: Obj): Boolean {
val otherDecl = typeDeclFromObj(scope, other) ?: return false
val leftKey = typeDeclKey(normalizeTypeDecl(scope, typeDecl))
val rightKey = typeDeclKey(normalizeTypeDecl(scope, otherDecl))
return leftKey == rightKey
}
override suspend fun contains(scope: Scope, other: Obj): Boolean {
val leftDecl = typeDeclFromObj(scope, other) ?: return false
val rightDecl = normalizeTypeDecl(scope, typeDecl)
return typeDeclIsSubtype(scope, leftDecl, rightDecl)
}
}
internal fun matchesTypeDecl(scope: Scope, value: Obj, typeDecl: TypeDecl): Boolean { internal fun matchesTypeDecl(scope: Scope, value: Obj, typeDecl: TypeDecl): Boolean {
if (value === ObjNull) { if (value === ObjNull) {
@ -54,3 +67,163 @@ internal fun matchesTypeDecl(scope: Scope, value: Obj, typeDecl: TypeDecl): Bool
is TypeDecl.Intersection -> typeDecl.options.all { matchesTypeDecl(scope, value, it) } is TypeDecl.Intersection -> typeDecl.options.all { matchesTypeDecl(scope, value, it) }
} }
} }
internal fun typeDeclFromObj(scope: Scope, value: Obj): TypeDecl? {
return when (value) {
is ObjTypeExpr -> normalizeTypeDecl(scope, value.typeDecl)
is ObjClass -> TypeDecl.Simple(value.className, false)
else -> null
}
}
internal fun typeDeclIsSubtype(scope: Scope, left: TypeDecl, right: TypeDecl): Boolean {
val lNorm = normalizeTypeDecl(scope, left)
val rNorm = normalizeTypeDecl(scope, right)
val lNullable = lNorm.isNullable || lNorm is TypeDecl.TypeNullableAny
val rNullable = rNorm.isNullable || rNorm is TypeDecl.TypeNullableAny
if (lNullable && !rNullable) return false
val l = stripNullable(lNorm)
val r = stripNullable(rNorm)
if (r == TypeDecl.TypeAny || r == TypeDecl.TypeNullableAny) return true
if (l == TypeDecl.TypeAny) return r == TypeDecl.TypeAny || r == TypeDecl.TypeNullableAny
if (l == TypeDecl.TypeNullableAny) return r == TypeDecl.TypeNullableAny
return when (l) {
is TypeDecl.Union -> l.options.all { typeDeclIsSubtype(scope, it, r) }
is TypeDecl.Intersection -> l.options.any { typeDeclIsSubtype(scope, it, r) }
else -> when (r) {
is TypeDecl.Union -> r.options.any { typeDeclIsSubtype(scope, l, it) }
is TypeDecl.Intersection -> r.options.all { typeDeclIsSubtype(scope, l, it) }
is TypeDecl.Simple, is TypeDecl.Generic, is TypeDecl.Function -> {
val leftClass = resolveTypeDeclClass(scope, l) ?: return false
val rightClass = resolveTypeDeclClass(scope, r) ?: return false
leftClass == rightClass || leftClass.allParentsSet.contains(rightClass)
}
else -> false
}
}
}
private fun normalizeTypeDecl(scope: Scope, decl: TypeDecl): TypeDecl {
val resolved = if (decl is TypeDecl.TypeVar) {
val bound = scope[decl.name]?.value
when (bound) {
is ObjTypeExpr -> bound.typeDecl
is ObjClass -> TypeDecl.Simple(bound.className, decl.isNullable)
else -> decl
}
} else decl
return when (resolved) {
is TypeDecl.Union -> normalizeUnion(scope, resolved)
is TypeDecl.Intersection -> normalizeIntersection(scope, resolved)
else -> resolved
}
}
private fun normalizeUnion(scope: Scope, decl: TypeDecl.Union): TypeDecl {
val options = mutableListOf<TypeDecl>()
var nullable = decl.isNullable
for (opt in decl.options) {
val norm = normalizeTypeDecl(scope, opt)
if (norm is TypeDecl.TypeNullableAny) nullable = true
val base = stripNullable(norm)
if (base == TypeDecl.TypeAny) return if (nullable) TypeDecl.TypeNullableAny else TypeDecl.TypeAny
if (base is TypeDecl.Union) {
options.addAll(base.options)
} else {
options += base
}
nullable = nullable || norm.isNullable
}
val unique = options.distinctBy { typeDeclKey(it) }.sortedBy { typeDeclKey(it) }
val base = if (unique.size == 1) unique[0] else TypeDecl.Union(unique, nullable = false)
return if (nullable) makeNullable(base) else base
}
private fun normalizeIntersection(scope: Scope, decl: TypeDecl.Intersection): TypeDecl {
val options = mutableListOf<TypeDecl>()
var nullable = decl.isNullable
for (opt in decl.options) {
val norm = normalizeTypeDecl(scope, opt)
val base = stripNullable(norm)
if (base == TypeDecl.TypeAny) {
nullable = nullable || norm.isNullable
continue
}
if (base is TypeDecl.Intersection) {
options.addAll(base.options)
} else {
options += base
}
nullable = nullable || norm.isNullable
}
val unique = options.distinctBy { typeDeclKey(it) }.sortedBy { typeDeclKey(it) }
val base = when {
unique.isEmpty() -> TypeDecl.TypeAny
unique.size == 1 -> unique[0]
else -> TypeDecl.Intersection(unique, nullable = false)
}
return if (nullable) makeNullable(base) else base
}
private fun stripNullable(type: TypeDecl): TypeDecl {
return if (!type.isNullable && type !is TypeDecl.TypeNullableAny) {
type
} else {
when (type) {
is TypeDecl.Function -> type.copy(nullable = false)
is TypeDecl.TypeVar -> type.copy(nullable = false)
is TypeDecl.Union -> type.copy(nullable = false)
is TypeDecl.Intersection -> type.copy(nullable = false)
is TypeDecl.Simple -> TypeDecl.Simple(type.name, false)
is TypeDecl.Generic -> TypeDecl.Generic(type.name, type.args, false)
else -> TypeDecl.TypeAny
}
}
}
private fun makeNullable(type: TypeDecl): TypeDecl {
return when (type) {
TypeDecl.TypeAny -> TypeDecl.TypeNullableAny
TypeDecl.TypeNullableAny -> type
is TypeDecl.Function -> type.copy(nullable = true)
is TypeDecl.TypeVar -> type.copy(nullable = true)
is TypeDecl.Union -> type.copy(nullable = true)
is TypeDecl.Intersection -> type.copy(nullable = true)
is TypeDecl.Simple -> TypeDecl.Simple(type.name, true)
is TypeDecl.Generic -> TypeDecl.Generic(type.name, type.args, true)
}
}
private fun typeDeclKey(type: TypeDecl): String = when (type) {
TypeDecl.TypeAny -> "Any"
TypeDecl.TypeNullableAny -> "Any?"
is TypeDecl.Simple -> "S:${type.name}"
is TypeDecl.Generic -> "G:${type.name}<${type.args.joinToString(",") { typeDeclKey(it) }}>"
is TypeDecl.Function -> "F:(${type.params.joinToString(",") { typeDeclKey(it) }})->${typeDeclKey(type.returnType)}"
is TypeDecl.TypeVar -> "V:${type.name}"
is TypeDecl.Union -> "U:${type.options.joinToString("|") { typeDeclKey(it) }}"
is TypeDecl.Intersection -> "I:${type.options.joinToString("&") { typeDeclKey(it) }}"
}
private fun resolveTypeDeclClass(scope: Scope, type: TypeDecl): ObjClass? {
return when (type) {
is TypeDecl.Simple -> {
val direct = scope[type.name]?.value as? ObjClass
direct ?: scope[type.name.substringAfterLast('.')]?.value as? ObjClass
}
is TypeDecl.Generic -> {
val direct = scope[type.name]?.value as? ObjClass
direct ?: scope[type.name.substringAfterLast('.')]?.value as? ObjClass
}
is TypeDecl.Function -> scope["Callable"]?.value as? ObjClass
is TypeDecl.TypeVar -> {
val bound = scope[type.name]?.value
when (bound) {
is ObjClass -> bound
is ObjTypeExpr -> resolveTypeDeclClass(scope, bound.typeDecl)
else -> null
}
}
else -> null
}
}

View File

@ -202,15 +202,24 @@ class TypesTest {
} }
@Test @Test
fun testUnioTypeLists() = runTest { fun testUnionTypeLists() = runTest {
eval(""" eval("""
fun f<T>(list: List<T>) { fun fMixed<T>(list: List<T>) {
println(list) println(list)
println(T) println(T)
assert( T is Int | String | Bool )
assert( !(T is Int) )
assert( Int in T )
assert( String in T )
} }
f([1, "two", true]) fun fInts<T>(list: List<T>) {
f([1,2,3]) assert( T is Int )
assert( Int in T )
assert( !(String in T) )
}
fMixed([1, "two", true])
fInts([1,2,3])
""") """)
} }
@ -226,8 +235,11 @@ class TypesTest {
R2("t").apply { R2("t").apply {
assertEquals("r2", r2) assertEquals("r2", r2)
assertEquals("t", shared) assertEquals("t", shared)
assertEquals("r1", this@R1.r1) assertEquals("s", this@R1.shared)
// actually we have now this of union type R1 & R2! // actually we have now this of union type R1 & R2!
// println(this::class)
assert( this@R2 is R2 )
assert( this@R1 is R1 )
} }
} }
""") """)

View File

@ -234,6 +234,18 @@ Object methods:
- keep `toString()` as Object method - keep `toString()` as Object method
- if we need extra metadata later, use explicit helpers like `Object.getHashCode(obj)` - if we need extra metadata later, use explicit helpers like `Object.getHashCode(obj)`
- Type expression checks (unions/intersections):
- Value check: `x is T` is runtime instance check (as usual).
- Type check: `T1 is T2` means type-subset (all values of `T1` fit in `T2`).
- Exact equality uses `==` and is structural (normalized unions/intersections).
- Includes uses `in`: `A in T` means `A` is a subset of `T`.
- Examples (T = A | B):
- `T == A` is false
- `T is A` is false
- `A in T` is true
- `B in T` is true
- `T is A | B` is true
- Builtin classes inheritance: Are Int/String final? If so, is "class T: String, Int" forbidden (and thus Int & String is unsatisfiable but still allowed)? - Builtin classes inheritance: Are Int/String final? If so, is "class T: String, Int" forbidden (and thus Int & String is unsatisfiable but still allowed)?
What keyword we did used for final vals/vars/funs? "closed"? Anyway I am uncertain whether to make Int or String closed, it is a discussion subject. But if we have some closed independent classes A, B, <T: A & B> is a compile time error. What keyword we did used for final vals/vars/funs? "closed"? Anyway I am uncertain whether to make Int or String closed, it is a discussion subject. But if we have some closed independent classes A, B, <T: A & B> is a compile time error.