Null Safety?
Table of Contents
Nothing's gonna harm you
Not while I'm around
Nothing's gonna harm you
No sir, not while I'm around
Concurnas, like most modern programming languages, has support within its type system for null
, this is useful but comes with one specific danger... In conventional programming languages supporting null
, since any object reference can be of type null
, then there is the potential for error if a field access or method invocation is attempted on that null instance. In Java this manifests itself via the dreaded and ubiquitous NullPointerException
. Given that any object can be null, at any point of execution, this error can occur anywhere. Thus, in order to write strictly safe code one is forced to validate one's object references as not being null on a persistent basis to be really sure that this potential for error is eradicated. This time consuming and labour intensive process is often either skipped entirely or only partially completed in most projects. Leading for the potential for error. In fact this problem is considered so severe that it's often referred to as the Billion Dollar Mistake.
So why not remove null
from the type system entirely? Well, it turns out that for some algorithms having null is incredibly useful for representing uninitialized state. So we need to keep null
. But we can manage its negative aspects.
Concurnas, like other modern programming languages such as Kotlin and Swift, makes working with null
easy and safe by incorporating nullability a part of the type system, and by providing a range of null safety operators to assist in those instances where null is a desired object state. This allows us to write safe code in Concurnas which is largely free of NullPointerException
exceptions.
Type system nullability?
All non-primitive types in Concurnas are non-nullable at the point of declaration by default. Thus, attempting to assign a null value to a variable having a non-nullable type (or anywhere else where a non-nullable type is expected, for instance in a method argument etc) will result in a compile time error:
aVariable String = "a String"
aVariable = null//this is a compile time error!
If one wishes for a variable to be able to hold a value of null, a nullable type must be declared, this can be achieved for a non-primitive type by appending a ?
to the end of the type declaration. As per our previous example:
nullableVar String? = "a String"//nullableVar can hold a null value
nullableVar = null//this is acceptable
With nullability as part of the type system we are able to write code such as the following safe in the knowledge that there is no possibility for a NullPointerException
exception to be thrown:
len = aVariable.length()
Attempting the same method call on nullableVar
results in a compile time error, as nullableVar
might be null:
len = nullableVar.length()//compile time error!
But of course not all instances of nullableVar
are null (otherwise there is no value to the code above), so how can we call the length
method? Lucky for us Concurnas has some clever logic to support working with nullable types and a a number of useful operators...
Smart null checking?
If a nullable variable is checked for nullability within a branching logic block, i.e. an if statement, then the fact that it has been established as being not null will be incorporated to any usage of said variable within the body of the if statement. So the following is valid code:
res = if(nullableVar <> null){
nullableVar.length()//we've already established that nullableVar is not null
}else{
-1
}
This logic is reasonably comprehensive and can cater for complex cases such as the below, where we establish that a lambda is null in one if statement branch, and so can conclude that is must be non-null within another:
alambda ((int) int)? = def (a int) => a+10
res = if(alambda == null){
0
}else{//we've established that alambda is not null
alambda(1)
}
This logic applies on an incorporated running basis within the if test, thus the following code is valid:
res = if(nullableVar <> null and nullableVar.length() > 2){
"ok"
}else{
"fail"
}
Furthermore, in cases where a nullable variable is assigned a non-nullable value, we know with certainty that said variable is now not nullable, Concurnas is able to make use of this information thus enabling the following code to be valid:
nullable String? = "not null"
//... potentially other code...
ok = nullable.length() //ok as we know at this point nullable isn't nullable
This logic is applied to branching control flow:
a=2; b=10
nullable String? = null
if(a > b){
nullable = "ok"
}else{
nullable = "also ok"
}
ok = nullable.length() //ok as we know at this point nullable isn't nullable
This inference logic extends to class fields with the caveat that calls to methods after the determination of non nullability will invalidate that inference (since it's possible such a method call may set our field in question to null). Thus the following holds:
class MyClass{
aString String?
def foo(){
aString = null
}
def inferNonNull(){
aString = "ok"
len = aString.length() //ok aString is not nullable
foo()//foo may set aString to null
len = aString.length() //error aString might be nullable
}
}
This logic does not apply to shared nullable variables since they can be set to null by any isolate having access to them.
Safe calls?
The safe call dot operator, ?.
allows us to execute the method or field access on the right hand side of a dot for a nullable entity if it is not null. If it is null, then null is returned. This the type returned from any call of this nature will always be nullable.
nullableVar String? = "a String"
len = nullableVar?.length() //len is of type Integer? (nullable Integer)
The safe call dot operator may only be applied to nullable entities, applying it to a non-nullable type results in a compilation error:
normalString String = "a String"
len = normalString?.length() //compilaton error
Array reference safe calls ?
Safe calls can be applied to array reference calls by inserting a ?
between the expression and array reference brackets []
as follows:
maybeNull int[]? = [1 2 3 4] if condition() else null //maybe null
got = maybeNull?[0] //got is of type Integer?
"" + got
Chained safe calls?
Safe calls can be chained together, this is quite a common usage pattern:
enum Color{BLUE, GREEN, RED}
class Child(-favColour Color)
class Parent(-children Child...)
parent Parent? = Parent(Child(Color.GREEN), Child(Color.RED))
firstChildColor = parent?.children?[0]?.favColour //chained safe calls
Elvis operator?
The Elvis operator1, ?:
in Concurnas serves a similar purpose to the safe dot operator above in that it allows us to react appropriately to the case where the expression on the left hand side of the operator resolves to null
. The difference with the Elvis operator is that when null
is found, the expression on the right hand side is evaluated and returned:
nullableVar String? = null
len int = (nullableVar?: "").length()
No null assertion?
The final option for working with nullable types in Concurnas is the no null assertion operator, ??
. This simply will throw a NullPointerException
if null
is found on the left hand side, otherwise it will return the value (which is guaranteed to be not nullable) of the left hand side. For example:
nullableString String? = "value"
//...
notNull String = nullableString?? //throws a NullPointerException if nullableString is null
The operator may be used on its own in order to test for nullness without returning a value:
nullableString String? = "value"
//...
nullableString?? //throws a NullPointerException if nullableString is null
The no null assertion, like many other operators, may be used preceding a dot operator:
nullableString String? = "value"
//...
length int = nullableString??.length() //throws a NullPointerException if nullableString is null
Nullable generics?
When it comes to generic types, at the point of declaration by default the are non-nullable, that is to say, the default upper bound of a generic type declaration is Object
- which is not nullable. i.e. Below the X
generic types of our TakesGeneric1
and TakesGeneric2
classes are the same:
class TakesGeneric1<X>
class TakesGeneric2<X Object> //Object is non-nullable
Since we have declared X
above as being non-nullable the following code is not compilable:
inst = TakesGeneric1<String?>() //compile time error
We could however redefine TakesGeneric1
in order to achieve this:
class TakesGeneric1<X?>
inst = TakesGeneric1<String?>() //this is ok
Classes having non-nullable generic types may still define nullable instances of those generic types as follows:
class TakesGeneric1<X>{
~x X?
}
inst = TakesGeneric1<String>()
inst.x = null //this is ok
Class field initialization?
Class fields if not initialized at point of declaration or via the constructor invocation chain will default to null. Class fields like this must be declared as being nullable otherwise they will be flagged up as an error at compilation time. For example:
class UninitNonNullables<X>{
aString String //initially set to null, but type not nullable - hence error!
anArray int[] //initially set to null, but type not nullable - hence error!
}
This can be solved by either declaring the variables as being of a nullable type or initializing them:
class UninitNonNullables<X>(anArray int[]){ //initialized in default constructor
aString String? //nullable
}
Using non-Concurnas types?
Code written outside of Concurnas, for instance in Java, may not have any null safety. As such, by default Concurnas is somewhat conservative when it comes to invoking methods originating from languages other than Concurnas. Though this does not come at a sacrifice to what code can be used, it does come with some caveats.
Essentially, unless otherwise annotated (see Annotating non-Concurnas code below), the methods of non-Concurnas types are assumed to consume and return values of unknown nullability. That is to say, they are assumed to be nullable but can be used as if they were both nullable and non-nullable. For example, a list:
alist = new list<String>() //generic param of java.util.ArrayList declared as a non-nullable type
alist.add('inst')
alist.add(null) //this is ok
res1 = alist.get(0) //res1 is of nullable type: String?
res2 String = alist.get(0)
The above would not be possible if the generic parameter of java.util.ArrayList
were to be known as either nullable or non-nullable - but it's unknown in this case. Let's look at what this causes in detail:
alist.add(null)
- it is acceptable for null to be passed for a type of unknown nullability.res1 = alist.get(0)
-res1
will be inferred to be a nullable type.res2 String = alist.get(0)
- The value resulting from execution of theget
method call will be checked to ensure that it is not null before being assigned tores2
which has been declared as being non-nullable. This helps avoid "null pollution" - i.e. anull
value being inadvertently assigned to a non-nullable variable.
In the above code where Concurnas attempts to avoid "null pollution" - if an unknown nullability type resolves to a null value and is set to a non-nullable variable (or say passed as an argument to a method expecting a non-null parameter) then this will result in a NullPointerException
. If the alist
variable were to be created with a nullable generic type: alist = new list<String?>()
then this logic would not be required, though of course we'd be unable to assign the return value of alist.get(0)
to the non-nullable variable: res2 String
. Throwing a NullPointerException
in order to avoid "null pollution" is not desirable, but necessary - and is a beneficial approach where the alternative is permitting "null pollution" because at least the NullPointerException
is thrown at the point of assignment/usage.
Annotating non-Concurnas code?
non-Concurnas code may be decorated with the @com.concurnas.lang.NoNull
annotation in order to indicate a type is not nullable. Types may also be declared as being explicitly nullable. Here is an example of this in action for some Java code:
import com.concurnas.lang.NoNull;
import com.concurnas.lang.NoNull.When;
import java.util.List;
public static @NoNull List<@NoNull String> addToList(@NoNull List<@NoNull String> addTo, @NoNull String item ){
addTo.add(item);
return addTo;
}
public static @NoNull(when = When.NEVER) List<@NoNull(when = When.NEVER) String> addToListNULL(@NoNull(when = When.NEVER) List<@NoNull(when = When.NEVER) String> addTo, @NoNull(when = When.NEVER) String item ){
addTo.add(item);
return addTo;
}
Above, for the addToList
method the following elements are tagged as non-null:
The return type:
java.util.List
The generic type qualification of the return type:
String
The first input argument of type:
java.util.List
The generic type qualification of the first input argument:
String
The second input argument of type:
java.util.List
The same applies for the addToListNULL
except all the elements above are tagged as being nullable.
Nullable wrapper?
Sometimes when working with objects having generic type parameters, it is not possible to qualify those parameters in a nullable manner, for instance with refs. This presents a problem if we wish to store a nullable type within such a container object. Concurnas provides the com.concurnas.lang.nullable.Nullable
class, auto imported as Nullable
to wrap around such as nullable type:
nullableinst = new Nullable<String?>():
nullableinst.set("ok")
maybenull String? = nullableinst.get()
Nullable method references?
Method reference types can be declared nullable by nesting their declaration within parenthesis and affixing a ?
as per normal. For example:
nullLambda ((int) int)? //nullable method reference
Where can NullPointerException's still occur?
There are some limited circumstances in which NullPointerException
may still occur:
When using non-Concurnas types or calling non-Concurnas code. Covered above.
When using the no null assertion. Covered above.
Values from non-Concurnas code. Concurnas attempts to avoid "null pollution" - as such it's possible for a
NullPointerException
exception to be thrown from checking that the return value of a unknown nullability type is non-null. This "null pollution" avoidance is not applied to the individual values of arrays as it would not be efficient to check every array in this manner. As such non-primitive arrays and n+1 dimensional arrays provided by non-Concurnas code should be used with care and attention to the possibility of null values being present within them.When using non-Concurnas code. Concurnas has no control over code authored in other languages, so they may throw a
NullPointerException
.
Footnotes
1So called as the token looks like the emoticon for Elvis Presley