Last week, I was at the FOSDEM conference. FOSDEM is specific in that it has multiple rooms, each dedicated to a different theme and organized by a team. I had two talks:
- Practical Introduction to OpenTelemetry Tracing, in the Monitoring and Observability devroom
- What I miss in Java, the perspective of a Kotlin developer, in the Friends of OpenJDK devroom
The second talk is from an earlier post. Martin Bonnin did a tweet from a single slide, and it created quite a stir, even attracting Brian Goetz.
In this post, I'd like to expand on the problem of nullability and how it's solved in Kotlin and Java and add my comments to the Twitter thread.
Nullability
I guess that everybody in software development with more than a couple of years of experience has heard the following quote:
I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
-- Tony Hoare
The basic idea behind null
is that one can define an uninitialized variable. If one calls a member of such a variable, the runtime locates the memory address of the variable... and fails to dereference it because there's nothing behind it.
Null values are found in many programming languages under different names:
- Python has
None
- JavaScript has
null
- So do Java, Scala, and Kotlin
- Ruby has
nil
- etc.
Some languages do not allow uninitialized values, such as Rust.
Null-safety in Kotlin
As I mentioned, Kotlin does allow null
values. However, they are baked into the type system. In Kotlin, every type X
has two indeed two types:
-
X
, which is non-nullable. No variable of typeX
can benull
. The compiler guarantees it.
val str: String = null
The code above won't compile.
-
X?
, which is nullable.
val str: String? = null
The code above does compile.
If Kotlin allows null
values, why do its proponents tout its null safety? The compiler refuses to call members on possible null values, i.e., nullable types.
val str: String? = getNullableString()
val int: Int? = str.toIntOrNull() //1
- Doesn't compile
The way to fix the above code is to check whether the variable is null
before calling its members:
val str: String? = getNullableString()
val int: Int? = if (str == null) null
else str.toIntOrNull()
The above approach is pretty boilerplate-y, so Kotlin offers the null-safe operator to achieve the same:
val str: String? = getNullableString()
val int: Int? = str?.toIntOrNull()
Null-safety in Java
Now that we have described how Kotlin manages null
values, it's time to check how Java does it. First, there are neither non-nullable types nor null-safe operators in Java. Thus, every variable can potentially be null
and should be considered so.
var MyString str = getMyString(); //1
var Integer anInt = null; //2
if (str != null) {
anInt = str.toIntOrNull();
}
-
String
has notoIntOrNull()
method, so let's pretendMyString
is a wrapper type and delegates toString
- A mutable reference is necessary
If you chain multiple calls, it's even worse as every return value can potentially be null
. To be on the safe side, we need to check whether the result of each method call is null
. The following snippet may throw a NullPointerException
:
var baz = getFoo().getBar().getBaz();
Here's the fixed but much more verbose version:
var foo = getFoo();
var bar = null;
var baz = null;
if (foo != null) {
bar = foo.getBar();
if (bar != null) {
baz = bar.getBaz();
}
}
For this reason, Java 8 introduced the Optional type. Optional
is a wrapper around a possibly null value. Other languages call it Maybe
, Option
, etc.
Java language's designers advise that a method returns:
- Type
X
ifX
cannot benull
- Type
Optional<X>
ifX
can benull
If we change the return type of all the above methods to Optional
, we can rewrite the code in a null-safe way - and get immutability on top:
final var baz = getFoo().flatMap(Foo::getBar)
.flatMap(Bar::getBaz)
.orElse(null);
My main argument regarding this approach is that the Optional
itself could be null
. The language doesn't guarantee that it's not. Also, it's not advised to use Optional
for method input parameters.
To cope with this, annotation-based libraries have popped up:
Project | Package | Non-null annotation | Nullable annotation |
---|---|---|---|
JSR 305 | javax.annotation |
@Nonnull |
@Nullable |
Spring | org.springframework.lang |
@NonNull |
@Nullable |
JetBrains | org.jetbrains.annotations |
@NotNull |
@Nullable |
Findbugs | edu.umd.cs.findbugs.annotations |
@NonNull |
@Nullable |
Eclipse | org.eclipse.jdt.annotation |
@NonNull |
@Nullable |
Checker framework | org.checkerframework.checker.nullness.qual |
@NonNull |
@Nullable |
JSpecify | org.jspecify |
@NonNull |
@Nullable |
Lombok | org.checkerframework.checker.nullness.qual |
@NonNull |
- |
However, different libraries work in different ways:
- Spring produces WARNING messages at compile-time
- FindBugs requires a dedicated execution
- Lombok generates code that adds a null check but throws a
NullPointerException
if it'snull
anyway - etc.
Thanks to Sébastien Deleuze for mentioning JSpecify, which I didn't know previously. It's an industry-wide effort to deal with the current mess. Of course, the famous XKCD comic immediately comes to mind:
I still hope it will work out!
Conclusion
Java was incepted when null
-safety was not a big concern. Hence, NullPointerException
occurrences are common. The only safe solution is to wrap every method call in a null
check. It works, but it's boilerplate-y and makes the code harder to read.
Multiple alternatives are available, but they have issues: they aren't bulletproof, compete with each other, and work very differently.
Developers praise Kotlin for its null
-safety: it's the result of its null
-handling mechanism baked into the language design. Java will never be able to compete with Kotlin in this regard, as Java language architects value backward compatibility over code safety. It's their decision, and it's probably a good one when one remembers the pain of migration from Python 2 to Python 3. However, as a developer, it makes Kotlin a much more attractive option than Java to me.
To go further:
Originally published at A Java Geek on February 12th, 2023
Top comments (20)
Any thoughts on what, if any, benefit one gets from Lombok's approach? If all it does is check, but throw a
NullPointerException
ifnull
, that doesn't seem any different than if you didn't check at all in the first place.Indeed 😅
The difference is that it checks for nullability before executing method logic so it prevents it from execution.
But it seems that all it does is throw the same exception that would be thrown if you didn't check to begin with. Or am I missing something?
Yes but here's an example:
Let's say you have this function :
Let's say person is NULL. Without the annotation, the method will start execution and will only raise the NPE when you it reaches
var name = person.getName()
statement. While when you use the annotation, nullability is checked before invocation of the method, hence interrupting the logic of the method.Yes, I understand what it does. My point is that what it does serves no useful purpose. Whether the
NullPointerException
is thrown by Lombok's generated null check or by the attempt to invoke a method on a null reference, the result is essentially the same behavior. Preventing execution of any of the method body, but still throwing the exception, would only make a difference if that exception was caught somewhere. And catching aNullPointerException
is a code smell, as is catching most runtime exceptions.I agree that Lombok's approach is barely useful, but also think there is one exception. It's basically what @oussama_lahmidi_43fd5e509 expressed by
// some logic
in his code example: if the logic has side effects (like sending a message to a queue or writing to a database), you probably wouldn't want that code to execute if the rest of the method fails due to such a programming mistake. Of course, this can only serve as a very small safety net to human errors, but might save you from bad consequences.Thanks. That is a good example. You have a bug to fix either way, but in that case Lombok's generated null check may minimize extent such as preventing writing to a DB or some other similar thing.
Thanks for this great article!
I always wondered; is there a good argument against a flag that would make the Java compiler become null aware? Introducing such a flag would obviously break backward compatibility, but for newly created projects it might solve the NPE problem.. and who knows, 10 years from now when enough projects/libraries have adopted the approach it might become the default?
You missed my my assumption in the conclusion. If you want to go beyond it, you'll need to ask the architects themselves
Nullability is good when used with immutable data. But it starts to bother you when working with stateful structures. For example the lazy initialization problem, where Kotlin developers needed to implement such ugly hacks like
lateinit
. Another example is web validation: initially you receive from your page a form mapped to a "draft" DTO where all fields are nullable. And after the validation you should map this DTO to another "clean" one where all required fields are non-nullable. So in some cases the static nullability becomes mostly annoying than really useful.The general problem is that in most cases the nullability can not be defined statically at compile time because it depends on the context. So I think a good programming language apart from nullable and non-nullable types should also consider a type with unchecked nullability.
It's an interesting viewpoint. Why would you use mutable data structures?
I find myself using more and more immutable ones. Even better, Kotlin extension functions allow you going from one immutable incomplete data structure to another immutable complete data structure. You can materialize in our code the valid state of your structures.
When you write something more complex that an API wrapper over a database, and your application becomes stateful, you need mutable structures. For example JPA, STM, UIs, Graphs: they are all based on stateful structures.
"When your application becomes stateful, you need mutable data structures" could be seen as a tautology but us wrong: you can create another immutable structure reflecti5 the new state.
The fact that JPA, designed 15 years ago, uses mutability is no proof. Just don't use it.
I have a question related to this statement:
Shouldn't it be possible to add an annotation-based approach for null safety to Java while maintaining backwards compatibility? Maybe adopting the specific approach of one of the many libraries, or a variation.
The problem is the compiler: Kotlin's is null aware, not Java's. Hence, it's able to deduce whether a variable can be null or not. Annotations require work and are error-prone
It's a great example of a YAGNI or PAGNI exception.
Most of the time the decisions you take can be reversed if it turns out not so great. So don't design a cathedral from day one, start with something simple and iterate.
It's a really important principle for developer sanity.
At the same time, there a few key areas that a senior developer should know where you should invest time and efforts in up-front design, because "fixing" the mistake after the fact sucks a lot.
See YAGNI and PAGNIs here
lukeplant.me.uk/blog/posts/yagni-e...
simonwillison.net/2021/Jul/1/pagnis/
I'm not sure I understand your comment in regard to the content 🤔
I mean that it's good that Java tries to fix the initial mistake in its type system soundness. But that's obviously much harder than it would have been if like Kotlin they got it right from the start. It's one area where upfront design is really worth it.
Got it now.
The thing is, Java is not trying to fix anything... yet?