The explicit nulls feature (enabled via a flag) changes the Scala type hierarchy so that reference types (e.g.
String) are non-nullable. We can still express nullability with union types: e.g.
val x: String | Null = null.
The implementation of the feature in Scala 3 can be conceptually divided in several parts:
- changes to the type hierarchy so that
Nullis only a subtype of
- a "translation layer" for Java interoperability that exposes the nullability in Java APIs
unsafeNullslanguage feature which enables implicit unsafe conversion between
T | Null
The explicit-nulls flag is currently disabled by default. It can be enabled via
-Yexplicit-nulls defined in
ScalaSettings.scala. All of the explicit-nulls-related changes should be gated behind the flag.
We change the type hierarchy so that
Null is only a subtype of
- modifying the notion of what is a nullable class (
SymDenotationsto include only
Any, which is used by
- changing the parent of
Definitionsto point to
There are some utility functions for nullable types in
NullOpsDecorator.scala. They are extension methods for
Type; hence we can use them in this way:
stripNullsyntactically strips all
Nulltypes in the union: e.g.
T | Null => T. This should only be used if we can guarantee
Tis a reference type.
thisis a nullable union.
thistype can have
nullvalue after erasure.
Types.scala, we also defined an extractor
OrNull to extract the non-nullable part of a nullable unions .
(tp: Type) match case OrNull(tp1) => // if tp is a nullable union: tp1 | Null case _ => // otherwise
The problem we're trying to solve here is: if we see a Java method
String foo(String), what should that method look like to Scala?
- since we should be able to pass
nullinto Java methods, the argument type should be
String | Null
- since Java methods might return
null, the return type should be
String | Null
At a high-level:
- we track the loading of Java fields and methods as they're loaded by the compiler
- we do this in two places:
Namer(for Java sources) and
- whenever we load a Java member, we "nullify" its argument and return types
The nullification logic lives in
The entry point is the function
def nullifyMember(sym: Symbol, tp: Type, isEnumValueDef: Boolean)(implicit ctx: Context): Type which, given a symbol, its "regular" type, and a boolean whether it is a Enum value definition, produces what the type of the symbol should be in the explicit nulls world.
- If the symbol is a Enum value definition or a
TYPE_field, we don't nullify the type
- If it is
toString()method or the constructor, or it has a
@NotNullannotation, we nullify the type, without a
Nullat the outmost level.
- Otherwise, we nullify the type in regular way.
@NotNull annotations are defined in
JavaNullInterop.scala for more details about how we nullify different types.
If the explicit nulls flag is enabled, the overriding check between Scala classes and Java classes is relaxed.
matches function in
Types.scala is used to select condidated for overriding check.
RefCheck.scala determines whether the overriding types are compatible.
Suppose we have a type bound
class C[T >: Null <: String], it becomes unapplicable in explicit nulls, since we don't have a type that is a supertype of
Null and a subtype of
Hence, when we read a type bound from Scala 2 Tasty or Scala 3 Tasty, the upper bound is nullified if the lower bound is exactly
Null. The example above would become
class C[T >: Null <: String | Null].
unsafeNulls language feature is currently disabled by default. It can be enabled by importing
scala.language.unsafeNulls or using
-language:unsafeNulls. The feature object is defined in
library/src/scalaShadowing/language.scala. We can use
config.Feature.enabled(nme.unsafeNulls) to check if this feature is enabled.
We use the
SafeNulls mode to track
unsafeNulls. If explicit nulls is enabled without
unsafeNulls, there is a
SafeNulls mode in the context; when
unsafeNulls is enabled,
SafeNulls mode will be removed from the context.
Since we want to allow selecting member on nullable values, when searching a member of a type, the
| Null part should be ignored. See
As typing happens, we accumulate a set of
NotNullInfos in the
NotNullInfo contains the set of
TermRefs that are known to be non-null at the current program point. See
Nullables.scala for how
NotNullInfos are computed.
During type-checking, when we type an identity or a select tree (in
typedSelect), we will call
toNotNullTermRef on the tree before return the typed tree. If the tree
x has nullable type
T|Null and it is known to be not null according to the
NotNullInfo and it is not on the lhs of assignment, then we cast it to
x.type & T using
The reason for casting to
x.type & T, as opposed to just
T, is that it allows us to support flow typing for paths of length greater than one.
abstract class Node: val x: String val next: Node | Null def f = val l: Node | Null = ??? if l != null && l.next != null then val third: l.next.next.type = l.next.next
def f = val l: Node | Null = ??? if l != null && l.$asInstanceOf$[l.type & Node].next != null then val third: l.$asInstanceOf$[l.type & Node].next.$asInstanceOf$[(l.type & Node).next.type & Node].next.type = l.$asInstanceOf$[l.type & Node].next.$asInstanceOf$[(l.type & Node).next.type & Node].next
Notice that in the example above
(l.type & Node).next.type & Node is still a stable path, so we can use it in the type and track it for flow typing.