Non-nullability

Types are non-nullable by default, meaning that null is a not normally a legal value for types. The following code therefore will not compile:

def s:String := null -- won't compile, as null is not a String

In order to permit nulls within a type, the type must be declared nullable by adding a question mark (?) to the end of the type.

def s:String? := null -- compiles fine!

As null is a valid String? (nullable String), but is not a valid String (non-nullable String), the nullable String? variable s may not be used where a non-nullable String is expected:

def s:String? := null
Console.printLine(s) -- error, doesn't compile

unless the compiler can prove that the value cannot actually be null at the point where it is used:

def s:String? := "Hello"
Console.printLine(s) -- compiles fine, because the value can't actually be null

The Frost compiler uses data flow analysis to determine whether a given value might actually be null at the point where it is used. This means that you can use control flow statements like if to ensure that a null value is not seen by code which is not expecting it:

def s:String? := someFunctionThatMightReturnNull()
if s !== null {
    Console.printLine(s)
}

Because the Console.printLine is only executed when s is not null, this code compiles fine. We could also have written:

def s:String? := someFunctionThatMightReturnNull()
assert s !== null
Console.printLine(s)

Because we cannot reach the Console.printLine without passing through an assert that s is non-null, then if s turns out to be null the program will either crash at the assert statement (if safety checks are enabled) or enter undefined behavior (if safety checks are disabled). Either way, the compiler can assume that s is non-null at the point where it is accessed.

Limitations

During this early phase of development, the Frost compiler does not yet understand that, assuming no side effects have been triggered, functions called with the same parameters will return the same values. This means that if you do something like:

def array := Array<String?>()
...
if array[0] !== null {
    Console.printLine(array[0]) -- error: array[0] might be null
}

then Frost will currently complain that array[0] might be null, even though you just checked that it isn't. The two references to array[0] are of course two separate function calls, and Frost does not yet recognize that they are guaranteed result in the same value and can be coalesced into a single call. Rewriting the code to make this clear to the compiler:

def array := Array<String?>()
...
def first := array[0]
if first !== null {
    Console.printLine(first) -- works fine
}

will resolve the situation. Keep in mind that you may run into this with nullable properties, which are also function calls even though they look like a simple field access.

This analysis will improve over time and we will eventually deal with this situation correctly. But even then, no compiler is as smart as a human. There will always be situations in which it is in theory possible to prove that a value cannot be null, but the Frost compiler is not actually able to do so and will report an error. The easiest way to work around this issue is to use a cast or force non-null operator to cast the value to a non-nullable type at the point of access.

Performance Considerations

Treating a member of a value type, such as Int, as if it were a full-fledged object requires an actual object instance to be created in order to hold it. For instance,

def o:Object := 5

allocates memory on the heap to hold an instance of the Int class because of the conversion to Object. This has an impact on both memory and performance, relative to always treating the value as an Int.

You might expect that the same situation would hold true if you were to write:

def i:Int? := 5

After all, the value can be set to null, and doesn't null imply that we're using a pointer? Not in this case: we really only need to be able to distinguish between null and non-null values, and all we need to do that is a single extra bit of storage. Int? is internally represented as an Int followed by a single extra byte to keep track of whether or not it is null. The performance impact of doing this is much lower than turning it into a heap-allocated object instance.