Skip to content

Commit 158ac0b

Browse files
committed
Interpret Java types as implicitly nullable
This commit adds a "translation layer" between Java types (which are implicitly nullable), and Scala's view of those same Java types (which most be explicitly nullable to be sound). e.g. given the Java method `String foo(String arg) { return arg; }` Scala should view the method as if had signature `def foo(arg: String|JavaNull): String|JavaNull` This transformation should happen both if the Java code is loaded from bytecode, or if it's loaded from source. The exact rules for how types are translated can be found in `JavaNullInterop.scala`.
1 parent 9a06fa2 commit 158ac0b

File tree

40 files changed

+572
-11
lines changed

40 files changed

+572
-11
lines changed

compiler/src/dotty/tools/dotc/core/Flags.scala

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,15 @@ object Flags {
394394
/** Symbol is an enum class or enum case (if used with case) */
395395
final val Enum: FlagSet = commonFlag(40, "<enum>")
396396

397+
/** A Java enum */
398+
final val JavaEnum: FlagConjunction = allOf(JavaDefined, Enum)
399+
400+
/** A Java enum trait */
401+
final val JavaEnumTrait: FlagConjunction = allOf(JavaDefined, Enum)
402+
403+
/** A Java enum value */
404+
final val JavaEnumValue: FlagConjunction = allOf(StableRealizable, JavaStatic, JavaDefined, Enum)
405+
397406
/** Labeled with `erased` modifier (erased value) */
398407
final val Erased: FlagSet = termFlag(42, "erased")
399408

@@ -482,7 +491,7 @@ object Flags {
482491
final val FromStartFlags: FlagSet =
483492
Module | Package | Deferred | Method.toCommonFlags | Case |
484493
HigherKinded.toCommonFlags | Param | ParamAccessor.toCommonFlags |
485-
Scala2ExistentialCommon | MutableOrOpaque | Touched | JavaStatic |
494+
Scala2ExistentialCommon | MutableOrOpaque | Touched | JavaStatic | JavaDefined | JavaEnumValue.toCommonFlags |
486495
CovariantOrOuter | ContravariantOrLabel | CaseAccessor.toCommonFlags |
487496
Extension.toCommonFlags | NonMember | Implicit | Implied | Permanent | Synthetic |
488497
SuperAccessorOrScala2x | Inline
@@ -679,15 +688,6 @@ object Flags {
679688
/** A Java companion object */
680689
final val JavaProtected: FlagConjunction = allOf(JavaDefined, Protected)
681690

682-
/** A Java enum */
683-
final val JavaEnum: FlagConjunction = allOf(JavaDefined, Enum)
684-
685-
/** A Java enum trait */
686-
final val JavaEnumTrait: FlagConjunction = allOf(JavaDefined, Enum)
687-
688-
/** A Java enum value */
689-
final val JavaEnumValue: FlagConjunction = allOf(StableRealizable, JavaStatic, JavaDefined, Enum)
690-
691691
/** Labeled private[this] */
692692
final val PrivateLocal: FlagConjunction = allOf(Private, Local)
693693

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package dotty.tools.dotc.core
2+
3+
import dotty.tools.dotc.core.Contexts.Context
4+
import dotty.tools.dotc.core.Flags.JavaDefined
5+
import dotty.tools.dotc.core.StdNames.{jnme, nme}
6+
import dotty.tools.dotc.core.Symbols.{Symbol, defn, _}
7+
import dotty.tools.dotc.core.Types.{AndType, AppliedType, LambdaType, MethodType, OrType, PolyType, Type, TypeAlias, TypeMap, TypeParamRef, TypeRef}
8+
9+
/** This module defines methods to interpret Java types, which are implicitly nullable,
10+
* as Scala types, which are explicitly nullable.
11+
*
12+
* The transformation from Java types to Scala types is (conceptually) a function `n`
13+
* that adheres to the following rules:
14+
* (1) n(T) = T|JavaNull if T is a reference type
15+
* (2) n(T) = T if T is a value type
16+
* (3) n(C[T]) = C[T]|JavaNull if C is Java-defined
17+
* (4) n(C[T]) = C[n(T)]|JavaNull if C is Scala-defined
18+
* (5) n(A|B) = n(A)|n(B)|JavaNull
19+
* (6) n(A&B) = n(A) & n(B)
20+
* (7) n((A1, ..., Am)R) = (n(A1), ..., n(Am))n(R) for a method with arguments (A1, ..., Am) and return type R
21+
* (8) n(T) = T otherwise
22+
*
23+
* Notice that since the transformation is only applied to types attached to Java symbols, it doesn't need
24+
* to handle the full spectrum of Scala types. Additionally, some kinds of symbols like constructors and
25+
* enum instances get special treatment.
26+
*/
27+
object JavaNullInterop {
28+
29+
/** Transforms the type `tp` of Java member `sym` to be explicitly nullable.
30+
* `tp` is needed because the type inside `sym` might not be set when this method is called.
31+
*
32+
* e.g. given a Java method
33+
* String foo(String arg) { return arg; }
34+
*
35+
* After calling `nullifyMember`, Scala will see the method as
36+
*
37+
* def foo(arg: String|JavaNull): String|JavaNull
38+
*
39+
* This nullability function uses `JavaNull` instead of vanilla `Null`, for usability.
40+
* This means that we can select on the return of `foo`:
41+
*
42+
* val len = foo("hello").length
43+
*
44+
* But the selection can throw an NPE if the returned value is `null`.
45+
*/
46+
def nullifyMember(sym: Symbol, tp: Type)(implicit ctx: Context): Type = {
47+
assert(ctx.settings.YexplicitNulls.value)
48+
assert(sym.is(JavaDefined), "can only nullify java-defined members")
49+
50+
// A list of members that are special-cased.
51+
val whitelist: Seq[NullifyPolicy] = Seq(
52+
// The `TYPE` field in every class: don't nullify.
53+
NoOpP(_.name == nme.TYPE_),
54+
// The `toString` method: don't nullify the return type.
55+
paramsOnlyP(_.name == nme.toString_),
56+
// Constructors: params are nullified, but the result type isn't.
57+
paramsOnlyP(_.isConstructor),
58+
// Java enum instances: don't nullify.
59+
NoOpP(_.is(Flags.JavaEnumValue))
60+
)
61+
62+
val (fromWhitelistTp, handled) = whitelist.foldLeft((tp, false)) {
63+
case (res@(_, true), _) => res
64+
case ((_, false), pol) =>
65+
if (pol.isApplicable(sym)) (pol(tp), true)
66+
else (tp, false)
67+
}
68+
69+
if (handled) {
70+
fromWhitelistTp
71+
} else {
72+
// Default case: nullify everything.
73+
nullifyType(tp)
74+
}
75+
}
76+
77+
/** A policy that special cases the handling of some symbol. */
78+
private sealed trait NullifyPolicy {
79+
/** Whether the policy applies to `sym`. */
80+
def isApplicable(sym: Symbol): Boolean
81+
/** Nullifies `tp` according to the policy. Should call `isApplicable` first. */
82+
def apply(tp: Type): Type
83+
}
84+
85+
/** A policy that leaves the passed-in type unchanged. */
86+
private case class NoOpP(trigger: Symbol => Boolean) extends NullifyPolicy {
87+
override def isApplicable(sym: Symbol): Boolean = trigger(sym)
88+
89+
override def apply(tp: Type): Type = tp
90+
}
91+
92+
/** A policy for handling a method or poly.
93+
* @param trigger determines whether the policy applies to a given symbol.
94+
* @param nnParams the indices of the method parameters that should be considered "non-null" (should not be nullified).
95+
* @param nnRes whether the result type should be nullified.
96+
*
97+
* For the purposes of both `nnParams` and `nnRes`, when a parameter or return type is not nullified,
98+
* this applies only at the top level. e.g. suppose we have a Java result type `Array[String]` and `nnRes` is set.
99+
* Scala will see `Array[String|JavaNull]`; the array element type is still nullified.
100+
*/
101+
private case class MethodP(trigger: Symbol => Boolean,
102+
nnParams: Seq[Int],
103+
nnRes: Boolean)(implicit ctx: Context) extends TypeMap with NullifyPolicy {
104+
override def isApplicable(sym: Symbol): Boolean = trigger(sym)
105+
106+
private def spare(tp: Type): Type = {
107+
nullifyType(tp).stripNull
108+
}
109+
110+
override def apply(tp: Type): Type = {
111+
tp match {
112+
case ptp: PolyType =>
113+
derivedLambdaType(ptp)(ptp.paramInfos, this(ptp.resType))
114+
case mtp: MethodType =>
115+
val paramTpes = mtp.paramInfos.zipWithIndex.map {
116+
case (paramInfo, index) =>
117+
// TODO(abeln): the sequence lookup can be optimized, because the indices
118+
// in it appear in increasing order.
119+
if (nnParams.contains(index)) spare(paramInfo) else nullifyType(paramInfo)
120+
}
121+
val resTpe = if (nnRes) spare(mtp.resType) else nullifyType(mtp.resType)
122+
derivedLambdaType(mtp)(paramTpes, resTpe)
123+
}
124+
}
125+
}
126+
127+
/** A policy that nullifies only method parameters (but not result types). */
128+
private def paramsOnlyP(trigger: Symbol => Boolean)(implicit ctx: Context): MethodP = {
129+
MethodP(trigger, nnParams = Seq.empty, nnRes = true)
130+
}
131+
132+
/** Nullifies a Java type by adding `| JavaNull` in the relevant places. */
133+
private def nullifyType(tpe: Type)(implicit ctx: Context): Type = {
134+
val nullMap = new JavaNullMap(alreadyNullable = false)
135+
nullMap(tpe)
136+
}
137+
138+
/** A type map that adds `| JavaNull`.
139+
* @param alreadyNullable whether the type being mapped is already nullable (at the outermost level).
140+
* This is needed so that `JavaNullMap(A | B)` gives back `(A | B) | JavaNull`,
141+
* instead of `(A|JavaNull | B|JavaNull) | JavaNull`.
142+
*/
143+
private class JavaNullMap(alreadyNullable: Boolean)(implicit ctx: Context) extends TypeMap {
144+
/** Should we nullify `tp` at the outermost level? */
145+
def needsTopLevelNull(tp: Type): Boolean = {
146+
!alreadyNullable && (tp match {
147+
case tp: TypeRef =>
148+
// We don't modify value types because they're non-nullable even in Java.
149+
// We don't modify `Any` because it's already nullable.
150+
!tp.symbol.isValueClass && !tp.isRef(defn.AnyClass)
151+
case _ => true
152+
})
153+
}
154+
155+
/** Should we nullify the type arguments to the given generic `tp`?
156+
* We only nullify the inside of Scala-defined generics.
157+
* This is because Java classes are _all_ nullified, so both `java.util.List[String]` and
158+
* `java.util.List[String|Null]` contain nullable elements.
159+
*/
160+
def needsNullArgs(tp: AppliedType): Boolean = {
161+
val AppliedType(tycons, _) = tp
162+
tycons.widenDealias match {
163+
case tp: TypeRef if !tp.symbol.is(JavaDefined) => true
164+
case _ => false
165+
}
166+
}
167+
168+
override def apply(tp: Type): Type = {
169+
tp match {
170+
case tp: LambdaType => mapOver(tp)
171+
case tp: TypeAlias => mapOver(tp)
172+
case tp@AndType(tp1, tp2) =>
173+
// nullify(A & B) = (nullify(A) & nullify(B)) | JavaNull, but take care not to add
174+
// duplicate `JavaNull`s at the outermost level inside `A` and `B`.
175+
val newMap = new JavaNullMap(alreadyNullable = true)
176+
derivedAndType(tp, newMap(tp1), newMap(tp2)).toJavaNullableUnion
177+
case tp@OrType(tp1, tp2) if !tp.isJavaNullableUnion =>
178+
val newMap = new JavaNullMap(alreadyNullable = true)
179+
derivedOrType(tp, newMap(tp1), newMap(tp2)).toJavaNullableUnion
180+
case tp: TypeRef if needsTopLevelNull(tp) => tp.toJavaNullableUnion
181+
case tp: TypeParamRef if needsTopLevelNull(tp) => tp.toJavaNullableUnion
182+
case appTp@AppliedType(tycons, targs) =>
183+
val targs2 = if (needsNullArgs(appTp)) targs map this else targs
184+
derivedAppliedType(appTp, tycons, targs2).toJavaNullableUnion
185+
case _ => tp
186+
}
187+
}
188+
}
189+
}

compiler/src/dotty/tools/dotc/core/Types.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,13 @@ object Types {
475475
if (diff) rem else this
476476
}
477477

478+
/** Injects this type into a union with `JavaNull`. */
479+
def toJavaNullableUnion(implicit ctx: Context): Type = {
480+
assert(ctx.settings.YexplicitNulls.value)
481+
if (this.isJavaNullableUnion) this
482+
else OrType(this, defn.JavaNullAliasType)
483+
}
484+
478485
// ----- Higher-order combinators -----------------------------------
479486

480487
/** Returns true if there is a part of this type that satisfies predicate `p`.

compiler/src/dotty/tools/dotc/core/classfile/ClassfileParser.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,10 @@ class ClassfileParser(
270270
if ((denot is Flags.Method) && (jflags & JAVA_ACC_VARARGS) != 0)
271271
denot.info = arrayToRepeated(denot.info)
272272

273+
if (ctx.settings.YexplicitNulls.value) {
274+
denot.info = JavaNullInterop.nullifyMember(denot.symbol, denot.info)
275+
}
276+
273277
// seal java enums
274278
if (isEnum) {
275279
val enumClass = sym.owner.linkedClass

compiler/src/dotty/tools/dotc/typer/Namer.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1397,7 +1397,12 @@ class Namer { typer: Typer =>
13971397
case _ =>
13981398
WildcardType
13991399
}
1400-
paramFn(checkSimpleKinded(typedAheadType(mdef.tpt, tptProto)).tpe)
1400+
val memTpe = paramFn(checkSimpleKinded(typedAheadType(mdef.tpt, tptProto)).tpe)
1401+
if (ctx.settings.YexplicitNulls.value && mdef.mods.is(JavaDefined)) {
1402+
JavaNullInterop.nullifyMember(sym, memTpe)
1403+
} else {
1404+
memTpe
1405+
}
14011406
}
14021407

14031408
/** The type signature of a DefDef with given symbol */
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class J {
2+
void foo(String[] ss) {}
3+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class S {
2+
3+
val j = new J()
4+
val x: Array[String] = ???
5+
j.foo(x) // error: expected Array[String|Null] but got Array[String]
6+
7+
val x2: Array[String|Null] = ???
8+
j.foo(x2) // ok
9+
j.foo(null) // ok
10+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
public enum Planet {
2+
MERCURY (3.303e+23, 2.4397e6),
3+
VENUS (4.869e+24, 6.0518e6),
4+
EARTH (5.976e+24, 6.37814e6),
5+
MARS (6.421e+23, 3.3972e6),
6+
JUPITER (1.9e+27, 7.1492e7),
7+
SATURN (5.688e+26, 6.0268e7),
8+
URANUS (8.686e+25, 2.5559e7),
9+
NEPTUNE (1.024e+26, 2.4746e7);
10+
11+
private final double mass; // in kilograms
12+
private final double radius; // in meters
13+
Planet(double mass, double radius) {
14+
this.mass = mass;
15+
this.radius = radius;
16+
}
17+
private double mass() { return mass; }
18+
private double radius() { return radius; }
19+
20+
// This method returns a `Planet`, but since `null` is a valid
21+
// return value, the return type should be nullified.
22+
// Contrast with accessing the static member corresponding to the enum
23+
// _instance_ (e.g. Planet.MERCURY) which shouldn't be nullified.
24+
Planet next() {
25+
return null;
26+
}
27+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
2+
// Verify that enum values aren't nullified.
3+
class S {
4+
val p: Planet = Planet.MARS // ok: accessing static member
5+
val p2: Planet = p.next() // error: expected Planet but got Planet|Null
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
2+
// Test that JavaNull can be assigned to Null.
3+
class Foo {
4+
import java.util.ArrayList
5+
val l = new ArrayList[String]()
6+
val s: String = l.get(0) // error: return type is nullable
7+
val s2: String|Null = l.get(0) // ok
8+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
2+
class J {
3+
String foo(String x) { return null; }
4+
static String fooStatic(String x) { return null; }
5+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
2+
class S {
3+
4+
val j = new J()
5+
j.foo(null) // ok: argument is nullable
6+
val s: String = j.foo("hello") // error: return type is nullable
7+
8+
J.fooStatic(null) // ok: argument is nullable
9+
val s2: String = J.fooStatic("hello") // error: return type is nullable
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class Foo {
2+
import java.util.ArrayList
3+
// Test that return values in PolyTypes are marked as nullable.
4+
val lstring = new ArrayList[String]()
5+
val res: String = java.util.Collections.max(lstring) // error: missing |Null
6+
val res2: String|Null = java.util.Collections.max(lstring) // ok
7+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class Foo {
2+
import java.util.ArrayList
3+
4+
// Test that as we extract return values, we're missing the |JavaNull in the return type.
5+
// i.e. test that the nullability is propagated to nested containers.
6+
val ll = new ArrayList[ArrayList[ArrayList[String]]]
7+
val level1: ArrayList[ArrayList[String]] = ll.get(0) // error
8+
val level2: ArrayList[String] = ll.get(0).get(0) // error
9+
val level3: String = ll.get(0).get(0).get(0) // error
10+
val ok: String = ll.get(0).get(0).get(0) // error
11+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
2+
// Test that the return type of Java methods as well as the type of Java fields is marked as nullable.
3+
class Foo {
4+
5+
def foo = {
6+
import java.util.ArrayList
7+
val x = new ArrayList[String]()
8+
val r: String = x.get(0) // error: got String|JavaNull instead of String
9+
10+
val x2 = new ArrayList[Int]()
11+
val r2: Int = x2.get(0) // error: even though Int is non-nullable in Scala, its counterpart
12+
// (for purposes of generics) in Java (Integer) is. So we're missing |JavaNull
13+
}
14+
}

tests/explicit-nulls/pos/array.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Test that array contents are non-nullable.
2+
class Foo {
3+
val x: Array[String] = Array("hello")
4+
val s: String = x(0)
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class J {
2+
private String s;
3+
4+
J(String x) { this.s = x; }
5+
J(String x, String y, String z) {}
6+
}

0 commit comments

Comments
 (0)