Implement nullable shorthand for params

This commit is contained in:
Sergey Chernov 2026-02-05 19:14:03 +03:00
parent 7e538ed8aa
commit 9db5b12c31
3 changed files with 205 additions and 5 deletions

View File

@ -224,6 +224,9 @@ This also prevents chain assignments so use parentheses:
## Nullability ## Nullability
Nullability is part of the type. `String` is non-null, `String?` is nullable. Use `!!` to assert non-null and throw
`NullReferenceException` if the value is `null`.
When the value is `null`, it might throws `NullReferenceException`, the name is somewhat a tradition. To avoid it 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, 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: the operation won't be performed and the result will be null. Here is the difference:
@ -242,6 +245,9 @@ the operation won't be performed and the result will be null. Here is the differ
assert( ref?() == null ) assert( ref?() == null )
>>> void >>> void
Note: `?.` is still a typed operation. The receiver must have a compile-time type that declares the member; if the
receiver is `Object`, cast it first or declare a more specific type.
There is also "elvis operator", null-coalesce infix operator '?:' that returns rvalue if lvalue is `null`: There is also "elvis operator", null-coalesce infix operator '?:' that returns rvalue if lvalue is `null`:
null ?: "nothing" null ?: "nothing"
@ -425,8 +431,6 @@ Almost the same, using `val`:
val foo = 1 val foo = 1
foo += 1 // this will throw exception foo += 1 // this will throw exception
# Constants
Same as in kotlin: Same as in kotlin:
val HalfPi = π / 2 val HalfPi = π / 2
@ -434,6 +438,133 @@ Same as in kotlin:
Note using greek characters in identifiers! All letters allowed, but remember who might try to read your script, most Note using greek characters in identifiers! All letters allowed, but remember who might try to read your script, most
likely will know some English, the rest is the pure uncertainty. likely will know some English, the rest is the pure uncertainty.
# Types and inference
Lyng uses Kotlin-style static types with inference. You can always write explicit types, but in most places the compiler
can infer them from literals, defaults, and flow analysis.
## Type annotations
Use `:` to specify a type:
var x: Int = 10
val label: String = "count"
fun clamp(x: Int, min: Int, max: Int): Int { ... }
`Object` is the top type. If you omit a type and there is no default value, the parameter is `Object` by default:
fun show(x) { println(x) } // x is Object
For nullable types, add `?`:
fun showMaybe(x: Object?) { ... }
fun parseInt(s: String?): Int? { ... }
There is also a nullable shorthand for untyped parameters and constructor args: `x?` means `x: Object?`.
It cannot be combined with an explicit type annotation.
class A(x?) { ... } // x: Object?
fun f(x?) { x == null } // x: Object?
`void` is a singleton value of the class `Void`. `Void` can be used as an explicit return type:
fun log(msg): Void { println(msg); void }
`Null` is the class of `null`. It is a singleton type and mostly useful for type inference results.
## Type inference
The compiler infers types from:
- literals: `1` is `Int`, `1.0` is `Real`, `"s"` is `String`, `'c'` is `Char`
- defaults: `fun f(x=1, name="n")` infers `x: Int`, `name: String`
- assignments: `val x = call()` uses the return type of `call`
- returns and branches: the result type of a block is the last expression, and if any branch is nullable,
the inferred type becomes nullable
- numeric ops: `Int` and `Real` stay `Int` when both sides are `Int`, and promote to `Real` on mixed arithmetic
Examples:
fun inc(x=0) = x + 1 // (Int)->Int
fun maybe(flag) { if(flag) 1 else null } // ()->Int?
Untyped locals are allowed, but their type is fixed on the first assignment:
var x
x = 1 // x becomes Int
x = "one" // compile-time error
var y = null // y is Object?
val z = null // z is Null
Empty list/map literals default to `List<Object>` and `Map<Object,Object>` until a more specific type is known:
val xs = [] // List<Object>
val ys: List<Int> = [] // List<Int>
## Flow analysis
Lyng uses flow analysis to narrow types inside branches:
fun len(x: String?): Int {
if( x == null ) return 0
// x is String (non-null) in this branch
return x.length
}
`is` checks and `when` branches also narrow types:
fun kind(x: Object) {
if( x is Int ) return "int"
if( x is String ) return "string"
return "other"
}
Narrowing is local to the branch; after the branch, the original type is restored.
## Casts and unknown types
Use `as` for explicit casts. The compiler inserts casts only when it can be valid and necessary. If a cast fails at
runtime, it throws `ClassCastException`. If the value is nullable, `as T` implies a non-null assertion.
Member access is resolved at compile time. Only members of `Object` are available on unknown types; non-Object members
require an explicit cast:
fun f(x) { // x is Object
x.toString() // ok (Object member)
x.size() // compile-time error
(x as List).size() // ok
}
This avoids runtime name-resolution fallbacks; all symbols must be known at compile time.
## Generics and bounds
Generic parameters are declared with `<...>`:
fun id<T>(x: T): T = x
class Box<T>(val value: T)
Bounds use `:` and can combine with `&` (intersection) and `|` (union):
fun sum<T: Int | Real>(x: T, y: T) = x + y
class Named<T: Iterable & Comparable>(val data: T)
Type arguments are usually inferred from call sites:
val b = Box(10) // Box<Int>
val s = id("ok") // T is String
## Variance
Generic types are invariant by default, so `List<Int>` is not a `List<Object>`.
Use declaration-site variance when you need it:
class Source<out T>(val value: T)
class Sink<in T> { fun accept(x: T) { ... } }
`out` makes the type covariant (only produced), `in` makes it contravariant (only consumed).
# Defining functions # Defining functions
fun check(amount) { fun check(amount) {
@ -1330,7 +1461,7 @@ than enum arrays, until `Lynon.encodeTyped` will be implemented.
var result = null // here we will store the result var result = null // here we will store the result
>>> null >>> null
# Integral data types # Built-in types
| type | description | literal samples | | type | description | literal samples |
|--------|---------------------------------|---------------------| |--------|---------------------------------|---------------------|
@ -1340,6 +1471,7 @@ than enum arrays, until `Lynon.encodeTyped` will be implemented.
| Char | single unicode character | `'S'`, `'\n'` | | Char | single unicode character | `'S'`, `'\n'` |
| String | unicode string, no limits | "hello" (see below) | | String | unicode string, no limits | "hello" (see below) |
| List | mutable list | [1, "two", 3] | | List | mutable list | [1, "two", 3] |
| Object | top type for all values | |
| Void | no value could exist, singleton | void | | Void | no value could exist, singleton | void |
| Null | missing value, singleton | null | | Null | missing value, singleton | null |
| Fn | callable type | | | Fn | callable type | |

View File

@ -2641,8 +2641,31 @@ class Compiler(
access access
} }
// Nullable shorthand: "x?" means Object? (or makes explicit type nullable)
var nullableHint = false
val nullableHintPos = cc.savePos()
cc.skipWsTokens()
if (cc.peekNextNonWhitespace().type == Token.Type.QUESTION) {
cc.nextNonWhitespace()
nullableHint = true
} else {
cc.restorePos(nullableHintPos)
}
// type information (semantic + mini syntax) // type information (semantic + mini syntax)
val (typeInfo, miniType) = parseTypeDeclarationWithMini() if (nullableHint) {
val afterHint = cc.savePos()
cc.skipWsTokens()
if (cc.peekNextNonWhitespace().type == Token.Type.COLON) {
throw ScriptError(t.pos, "nullable shorthand '?' cannot be combined with explicit type")
}
cc.restorePos(afterHint)
}
var (typeInfo, miniType) = parseTypeDeclarationWithMini()
if (nullableHint) {
typeInfo = makeTypeDeclNullable(typeInfo)
miniType = miniType?.let { makeMiniTypeNullable(it) }
}
var defaultValue: Statement? = null var defaultValue: Statement? = null
cc.ifNextIs(Token.Type.ASSIGN) { cc.ifNextIs(Token.Type.ASSIGN) {
@ -2713,6 +2736,31 @@ class Compiler(
return parseTypeUnionWithMini() return parseTypeUnionWithMini()
} }
private fun makeTypeDeclNullable(type: TypeDecl): TypeDecl {
if (type.isNullable) return type
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 makeMiniTypeNullable(type: MiniTypeRef): MiniTypeRef {
return when (type) {
is MiniTypeName -> type.copy(nullable = true)
is MiniGenericType -> type.copy(nullable = true)
is MiniFunctionType -> type.copy(nullable = true)
is MiniTypeVar -> type.copy(nullable = true)
is MiniTypeUnion -> type.copy(nullable = true)
is MiniTypeIntersection -> type.copy(nullable = true)
}
}
private fun parseTypeUnionWithMini(): Pair<TypeDecl, MiniTypeRef> { private fun parseTypeUnionWithMini(): Pair<TypeDecl, MiniTypeRef> {
var left = parseTypeIntersectionWithMini() var left = parseTypeIntersectionWithMini()
val options = mutableListOf(left) val options = mutableListOf(left)

View File

@ -146,4 +146,24 @@ class TypesTest {
println("Result: "+limit) println("Result: "+limit)
""".trimIndent()) """.trimIndent())
} }
@Test
fun testNullableHints() = runTest {
eval("""
// nullable, without type os Object?
class N(x=null)
assertEquals(null, N().x)
assertEquals("foo", N("foo").x)
// nullable shortcut (x?)is same as (var x: Object?)
class A(x?)
assertEquals(null, A(null).x)
assertEquals("ok", A("ok").x)
// same in function: x? is a shortcut for (x: Object?)
fun f(x?) = x?.let { x + "!" }
assertEquals(null, f(null))
assertEquals("ok!", f("ok"))
""".trimIndent()
)
}
} }