fix #19 set of null-coalesce operators

This commit is contained in:
Sergey Chernov 2025-06-13 22:25:18 +04:00
parent bd2b6bf06e
commit b961296425
9 changed files with 165 additions and 29 deletions

View File

@ -131,7 +131,7 @@ _this functionality is not yet released_
| class | notes |
|----------------------------|-------------------------------------------------------|
| Exception | root of al throwable objects |
| NullPointerException | |
| NullReferenceException | |
| AssertionFailedException | |
| ClassCastException | |
| IndexOutOfBoundsException | |

View File

@ -118,6 +118,51 @@ These operators return rvalue, unmodifiable.
## Assignment return r-value!
Naturally, assignment returns its value:
var x
x = 11
>>> 11
rvalue means you cant assign the result if the assignment
var x
assertThrows { (x = 11) = 5 }
void
>>> void
This also prevents chain assignments so use parentheses:
var x
var y
x = (y = 1)
>>> 1
## Nullability
When the value is `null`, it might throws `NullReferenceException`, the name is somewhat a tradition. To avoid it
one can check it against null or use _null coalescing_. The null coalescing means, if the operand (left) is null,
the operation won't be performed and the result will be null. Here is the difference:
val ref = null
assertThrows { ref.field }
assertThrows { ref.method() }
assertThrows { ref.array[1] }
assertThrows { ref[1] }
assertThrows { ref() }
assert( ref?.field == null )
assert( ref?.method() == null )
assert( ref?.array?[1] == null )
assert( ref?[1] == null )
assert( ref?() == null )
>>> void
There is also "elvis operator", null-coalesce infix operator '?:' that returns rvalue if lvalue is `null`:
null ?: "nothing"
>>> "nothing"
## Math
It is rather simple, like everywhere else:

View File

@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych"
version = "0.6.1-SNAPSHOT"
version = "0.6.-SNAPSHOT"
buildscript {
repositories {

View File

@ -121,7 +121,8 @@ class Compiler(
operand = Accessor { op.getter(it).value.logicalNot(it).asReadonly }
}
Token.Type.DOT -> {
Token.Type.DOT, Token.Type.NULL_COALESCE -> {
var isOptional = t.type == Token.Type.NULL_COALESCE
operand?.let { left ->
// dotcall: calling method on the operand, if next is ID, "("
var isCall = false
@ -138,17 +139,22 @@ class Compiler(
operand = Accessor { context ->
context.pos = next.pos
val v = left.getter(context).value
ObjRecord(
v.invokeInstanceMethod(
context,
next.value,
args.toArguments(context, false)
), isMutable = false
)
if (v == ObjNull && isOptional)
ObjNull.asReadonly
else
ObjRecord(
v.invokeInstanceMethod(
context,
next.value,
args.toArguments(context, false)
), isMutable = false
)
}
}
Token.Type.LBRACE -> {
Token.Type.LBRACE, Token.Type.NULL_COALESCE_BLOCKINVOKE -> {
isOptional = nt.type == Token.Type.NULL_COALESCE_BLOCKINVOKE
// single lambda arg, like assertTrows { ... }
cc.next()
isCall = true
@ -159,13 +165,16 @@ class Compiler(
operand = Accessor { context ->
context.pos = next.pos
val v = left.getter(context).value
ObjRecord(
v.invokeInstanceMethod(
context,
next.value,
Arguments(listOf(lambda), true)
), isMutable = false
)
if (v == ObjNull && isOptional)
ObjNull.asReadonly
else
ObjRecord(
v.invokeInstanceMethod(
context,
next.value,
Arguments(listOf(lambda), true)
), isMutable = false
)
}
}
@ -174,25 +183,30 @@ class Compiler(
}
if (!isCall) {
operand = Accessor({ context ->
left.getter(context).value.readField(context, next.value)
val x = left.getter(context).value
if (x == ObjNull && isOptional) ObjNull.asReadonly
else x.readField(context, next.value)
}) { cc, newValue ->
left.getter(cc).value.writeField(cc, next.value, newValue)
}
}
} ?: throw ScriptError(t.pos, "Expecting expression before dot")
}
?: throw ScriptError(t.pos, "Expecting expression before dot")
}
Token.Type.COLONCOLON -> {
operand = parseScopeOperator(operand, cc)
}
Token.Type.LPAREN -> {
Token.Type.LPAREN, Token.Type.NULL_COALESCE_INVOKE -> {
operand?.let { left ->
// this is function call from <left>
operand = parseFunctionCall(
cc,
left,
false,
t.type == Token.Type.NULL_COALESCE_INVOKE
)
} ?: run {
// Expression in parentheses
@ -205,15 +219,18 @@ class Compiler(
}
}
Token.Type.LBRACKET -> {
Token.Type.LBRACKET, Token.Type.NULL_COALESCE_INDEX -> {
operand?.let { left ->
// array access
val isOptional = t.type == Token.Type.NULL_COALESCE_INDEX
val index = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting index expression")
cc.skipTokenOfType(Token.Type.RBRACKET, "missing ']' at the end of the list literal")
operand = Accessor({ cxt ->
val i = (index.execute(cxt) as? ObjInt)?.value?.toInt()
?: cxt.raiseError("index must be integer")
left.getter(cxt).value.getAt(cxt, i).asMutable
val x = left.getter(cxt).value
if( x == ObjNull && isOptional) ObjNull.asReadonly
else x.getAt(cxt, i).asMutable
}) { cxt, newValue ->
val i = (index.execute(cxt) as? ObjInt)?.value?.toInt()
?: cxt.raiseError("index must be integer")
@ -337,10 +354,15 @@ class Compiler(
}
}
Token.Type.LBRACE -> {
Token.Type.LBRACE, Token.Type.NULL_COALESCE_BLOCKINVOKE -> {
operand = operand?.let { left ->
cc.previous()
parseFunctionCall(cc, left, blockArgument = true)
parseFunctionCall(
cc,
left,
blockArgument = true,
t.type == Token.Type.NULL_COALESCE_BLOCKINVOKE
)
} ?: parseLambdaExpression(cc)
}
@ -590,7 +612,12 @@ class Compiler(
}
private fun parseFunctionCall(cc: CompilerContext, left: Accessor, blockArgument: Boolean): Accessor {
private fun parseFunctionCall(
cc: CompilerContext,
left: Accessor,
blockArgument: Boolean,
isOptional: Boolean
): Accessor {
// insofar, functions always return lvalue
var detectedBlockArgument = blockArgument
val args = if (blockArgument) {
@ -607,6 +634,7 @@ class Compiler(
return Accessor { context ->
val v = left.getter(context)
if (v.value == ObjNull && isOptional) return@Accessor v.value.asReadonly
v.value.callOn(
context.copy(
context.pos,
@ -1600,6 +1628,9 @@ class Compiler(
Operator.simple(Token.Type.NOTIN, lastPrty) { c, a, b -> ObjBool(!b.contains(c, a)) },
Operator.simple(Token.Type.IS, lastPrty) { c, a, b -> ObjBool(a.isInstanceOf(b)) },
Operator.simple(Token.Type.NOTIS, lastPrty) { c, a, b -> ObjBool(!a.isInstanceOf(b)) },
Operator.simple(Token.Type.ELVIS, ++lastPrty) { c, a, b -> if( a == ObjNull) b else a },
// shuttle <=> 6
Operator.simple(Token.Type.SHUTTLE, ++lastPrty) { c, a, b ->
ObjInt(a.compareTo(c, b).toLong())

View File

@ -16,7 +16,7 @@ class Context(
fun raiseNotImplemented(what: String = "operation"): Nothing = raiseError("$what is not implemented")
@Suppress("unused")
fun raiseNPE(): Nothing = raiseError(ObjNullPointerException(this))
fun raiseNPE(): Nothing = raiseError(ObjNullReferenceException(this))
@Suppress("unused")
fun raiseIndexOutOfBounds(message: String = "Index out of bounds"): Nothing =

View File

@ -289,6 +289,26 @@ object ObjNull : Obj() {
return other is ObjNull || other == null
}
override suspend fun readField(context: Context, name: String): ObjRecord {
context.raiseNPE()
}
override suspend fun invokeInstanceMethod(context: Context, name: String, args: Arguments): Obj {
context.raiseNPE()
}
override suspend fun getAt(context: Context, index: Int): Obj {
context.raiseNPE()
}
override suspend fun putAt(context: Context, index: Int, newValue: Obj) {
context.raiseNPE()
}
override suspend fun callOn(context: Context): Obj {
context.raiseNPE()
}
override fun toString(): String = "null"
}
@ -383,7 +403,7 @@ open class ObjException(exceptionClass: ExceptionClass, val context: Context, va
context.addConst("Exception", Root)
existingErrorClasses["Exception"] = Root
for (name in listOf(
"NullPointerException",
"NullReferenceException",
"AssertionFailedException",
"ClassCastException",
"IndexOutOfBoundsException",
@ -400,7 +420,7 @@ open class ObjException(exceptionClass: ExceptionClass, val context: Context, va
}
}
class ObjNullPointerException(context: Context) : ObjException("NullPointerException", context, "object is null")
class ObjNullReferenceException(context: Context) : ObjException("NullReferenceException", context, "object is null")
class ObjAssertionFailedException(context: Context, message: String) :
ObjException("AssertionFailedException", context, message)

View File

@ -267,6 +267,21 @@ private class Parser(fromPos: Pos) {
Token(value.toString(), start, Token.Type.CHAR)
}
'?' -> {
when(currentChar.also { pos.advance() }) {
':' -> Token("??", from, Token.Type.ELVIS)
'?' -> Token("??", from, Token.Type.ELVIS)
'.' -> Token("?.", from, Token.Type.NULL_COALESCE)
'[' -> Token("?(", from, Token.Type.NULL_COALESCE_INDEX)
'(' -> Token("?(", from, Token.Type.NULL_COALESCE_INVOKE)
'{' -> Token("?{", from, Token.Type.NULL_COALESCE_BLOCKINVOKE)
else -> {
pos.back()
Token("?", from, Token.Type.QUESTION)
}
}
}
else -> {
// text infix operators:
// Labels processing is complicated!

View File

@ -21,6 +21,11 @@ data class Token(val value: String, val pos: Pos, val type: Type) {
ELLIPSIS, DOTDOT, DOTDOTLT,
NEWLINE,
EOF,
NULL_COALESCE,
ELVIS,
NULL_COALESCE_INDEX,
NULL_COALESCE_INVOKE,
NULL_COALESCE_BLOCKINVOKE,
}
companion object {

View File

@ -2113,4 +2113,24 @@ class ScriptTest {
""".trimIndent()
)
}
@Test
fun testNull1() = runTest {
eval("""
var s = null
assertThrows { s.length }
assertThrows { s.size() }
assertEquals( null, s?.size() )
assertEquals( null, s?.length )
assertEquals( null, s?.length ?{ "test" } )
assertEquals( null, s?[1] )
assertEquals( null, s ?{ "test" } )
assertEquals( null, s.test ?{ "test" } )
s = "xx"
assert(s.lower().size == 2)
assert(s.length == 2)
""".trimIndent())
}
}