Skip to content

Commit 73882c5

Browse files
authored
add flexible types to deal with Java-defined signatures under -Yexplicit-nulls (#18112)
This is a continuation of #17369. When dealing with reference types from Java, it's essential to address the implicit nullability of these types. The most accurate way to represent them in Scala is to use nullable types, though working with lots of nullable types directly can be annoying. To streamline interactions with Java libraries, we introduce the concept of flexible types. The flexible type, denoted by `T?`, functions as an abstract type with unique bounds: `T | Null ... T`, ensuring that `T | Null <: T? <: T`. The subtyping rule treats a reference type coming from Java as either nullable or non-nullable depending on the context. This concept draws inspiration from Kotlin's [platform types](https://kotlinlang.org/docs/java-interop.html#null-safety-and-platform-types). By relaxing null checks for such types, Scala aligns its safety guarantees with those of Java. Notably, flexible types are non-denotable, meaning users cannot explicitly write them in the code; only the compiler can construct or infer these types. Consequently, a value with a flexible type can serve as both a nullable and non-nullable value. Additionally, both nullable and non-nullable values can be passed as parameters with flexible types during function calls. Invoking the member functions of a flexible type is allowed, but it can trigger a `NullPointerException` if the value is indeed `null` during runtime. ```scala // Considering class J is from Java class J { // Translates to def f(s: String?): Unit public void f(String s) { } // Translates to def g(): String? public String g() { return ""; } } // Use J in Scala def useJ(j: J) = val x1: String = "" val x2: String | Null = null j.f(x1) // Passing String to String? j.f(x2) // Passing String | Null to String? j.f(null) // Passing Null to String? // Assign String? to String val y1: String = j.g() // Assign String? to String | Null val y2: String | Null = j.g() // Calling member functions on flexible types j.g().trim().length() ```
2 parents 32afee9 + 08b0fec commit 73882c5

File tree

73 files changed

+541
-158
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

73 files changed

+541
-158
lines changed

compiler/src/dotty/tools/dotc/config/ScalaSettings.scala

+1
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ private sealed trait YSettings:
417417
// Experimental language features
418418
val YnoKindPolymorphism: Setting[Boolean] = BooleanSetting(ForkSetting, "Yno-kind-polymorphism", "Disable kind polymorphism.")
419419
val YexplicitNulls: Setting[Boolean] = BooleanSetting(ForkSetting, "Yexplicit-nulls", "Make reference types non-nullable. Nullable types can be expressed with unions: e.g. String|Null.")
420+
val YnoFlexibleTypes: Setting[Boolean] = BooleanSetting(ForkSetting, "Yno-flexible-types", "Disable turning nullable Java return types and parameter types into flexible types, which behave like abstract types with a nullable lower bound and non-nullable upper bound.")
420421
val YcheckInit: Setting[Boolean] = BooleanSetting(ForkSetting, "Ysafe-init", "Ensure safe initialization of objects.")
421422
val YcheckInitGlobal: Setting[Boolean] = BooleanSetting(ForkSetting, "Ysafe-init-global", "Check safe initialization of global objects.")
422423
val YrequireTargetName: Setting[Boolean] = BooleanSetting(ForkSetting, "Yrequire-targetName", "Warn if an operator is defined without a @targetName annotation.")

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -696,9 +696,11 @@ trait ConstraintHandling {
696696
tp.rebind(tp.parent.hardenUnions)
697697
case tp: HKTypeLambda =>
698698
tp.derivedLambdaType(resType = tp.resType.hardenUnions)
699+
case tp: FlexibleType =>
700+
tp.derivedFlexibleType(tp.hi.hardenUnions)
699701
case tp: OrType =>
700-
val tp1 = tp.stripNull
701-
if tp1 ne tp then tp.derivedOrType(tp1.hardenUnions, defn.NullType)
702+
val tp1 = tp.stripNull(stripFlexibleTypes = false)
703+
if tp1 ne tp then tp.derivedOrType(tp1.hardenUnions, defn.NullType, soft = false)
702704
else tp.derivedOrType(tp.tp1.hardenUnions, tp.tp2.hardenUnions, soft = false)
703705
case _ =>
704706
tp

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

+3
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,9 @@ object Contexts {
472472
/** Is the explicit nulls option set? */
473473
def explicitNulls: Boolean = base.settings.YexplicitNulls.value
474474

475+
/** Is the flexible types option set? */
476+
def flexibleTypes: Boolean = base.settings.YexplicitNulls.value && !base.settings.YnoFlexibleTypes.value
477+
475478
/** A fresh clone of this context embedded in this context. */
476479
def fresh: FreshContext = freshOver(this)
477480

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,7 @@ class Definitions {
648648
@tu lazy val StringModule: Symbol = StringClass.linkedClass
649649
@tu lazy val String_+ : TermSymbol = enterMethod(StringClass, nme.raw.PLUS, methOfAny(StringType), Final)
650650
@tu lazy val String_valueOf_Object: Symbol = StringModule.info.member(nme.valueOf).suchThat(_.info.firstParamTypes match {
651-
case List(pt) => pt.isAny || pt.stripNull.isAnyRef
651+
case List(pt) => pt.isAny || pt.stripNull().isAnyRef
652652
case _ => false
653653
}).symbol
654654

@@ -660,13 +660,13 @@ class Definitions {
660660
@tu lazy val ClassCastExceptionClass: ClassSymbol = requiredClass("java.lang.ClassCastException")
661661
@tu lazy val ClassCastExceptionClass_stringConstructor: TermSymbol = ClassCastExceptionClass.info.member(nme.CONSTRUCTOR).suchThat(_.info.firstParamTypes match {
662662
case List(pt) =>
663-
pt.stripNull.isRef(StringClass)
663+
pt.stripNull().isRef(StringClass)
664664
case _ => false
665665
}).symbol.asTerm
666666
@tu lazy val ArithmeticExceptionClass: ClassSymbol = requiredClass("java.lang.ArithmeticException")
667667
@tu lazy val ArithmeticExceptionClass_stringConstructor: TermSymbol = ArithmeticExceptionClass.info.member(nme.CONSTRUCTOR).suchThat(_.info.firstParamTypes match {
668668
case List(pt) =>
669-
pt.stripNull.isRef(StringClass)
669+
pt.stripNull().isRef(StringClass)
670670
case _ => false
671671
}).symbol.asTerm
672672

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

+17-13
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,11 @@ object JavaNullInterop {
7878
* but the result type is not nullable.
7979
*/
8080
private def nullifyExceptReturnType(tp: Type)(using Context): Type =
81-
new JavaNullMap(true)(tp)
81+
new JavaNullMap(outermostLevelAlreadyNullable = true)(tp)
8282

8383
/** Nullifies a Java type by adding `| Null` in the relevant places. */
8484
private def nullifyType(tp: Type)(using Context): Type =
85-
new JavaNullMap(false)(tp)
85+
new JavaNullMap(outermostLevelAlreadyNullable = false)(tp)
8686

8787
/** A type map that implements the nullification function on types. Given a Java-sourced type, this adds `| Null`
8888
* in the right places to make the nulls explicit in Scala.
@@ -96,25 +96,29 @@ object JavaNullInterop {
9696
* to `(A & B) | Null`, instead of `(A | Null & B | Null) | Null`.
9797
*/
9898
private class JavaNullMap(var outermostLevelAlreadyNullable: Boolean)(using Context) extends TypeMap {
99+
def nullify(tp: Type): Type = if ctx.flexibleTypes then FlexibleType(tp) else OrNull(tp)
100+
99101
/** Should we nullify `tp` at the outermost level? */
100102
def needsNull(tp: Type): Boolean =
101-
!outermostLevelAlreadyNullable && (tp match {
102-
case tp: TypeRef =>
103+
if outermostLevelAlreadyNullable then false
104+
else tp match
105+
case tp: TypeRef if
103106
// We don't modify value types because they're non-nullable even in Java.
104-
!tp.symbol.isValueClass &&
107+
tp.symbol.isValueClass
108+
// We don't modify unit types.
109+
|| tp.isRef(defn.UnitClass)
105110
// We don't modify `Any` because it's already nullable.
106-
!tp.isRef(defn.AnyClass) &&
111+
|| tp.isRef(defn.AnyClass)
107112
// We don't nullify Java varargs at the top level.
108113
// Example: if `setNames` is a Java method with signature `void setNames(String... names)`,
109114
// then its Scala signature will be `def setNames(names: (String|Null)*): Unit`.
110115
// This is because `setNames(null)` passes as argument a single-element array containing the value `null`,
111116
// and not a `null` array.
112-
!tp.isRef(defn.RepeatedParamClass)
117+
|| !ctx.flexibleTypes && tp.isRef(defn.RepeatedParamClass) => false
113118
case _ => true
114-
})
115119

116120
override def apply(tp: Type): Type = tp match {
117-
case tp: TypeRef if needsNull(tp) => OrNull(tp)
121+
case tp: TypeRef if needsNull(tp) => nullify(tp)
118122
case appTp @ AppliedType(tycon, targs) =>
119123
val oldOutermostNullable = outermostLevelAlreadyNullable
120124
// We don't make the outmost levels of type arguments nullable if tycon is Java-defined.
@@ -124,7 +128,7 @@ object JavaNullInterop {
124128
val targs2 = targs map this
125129
outermostLevelAlreadyNullable = oldOutermostNullable
126130
val appTp2 = derivedAppliedType(appTp, tycon, targs2)
127-
if needsNull(tycon) then OrNull(appTp2) else appTp2
131+
if needsNull(tycon) then nullify(appTp2) else appTp2
128132
case ptp: PolyType =>
129133
derivedLambdaType(ptp)(ptp.paramInfos, this(ptp.resType))
130134
case mtp: MethodType =>
@@ -138,12 +142,12 @@ object JavaNullInterop {
138142
// nullify(A & B) = (nullify(A) & nullify(B)) | Null, but take care not to add
139143
// duplicate `Null`s at the outermost level inside `A` and `B`.
140144
outermostLevelAlreadyNullable = true
141-
OrNull(derivedAndType(tp, this(tp.tp1), this(tp.tp2)))
142-
case tp: TypeParamRef if needsNull(tp) => OrNull(tp)
145+
nullify(derivedAndType(tp, this(tp.tp1), this(tp.tp2)))
146+
case tp: TypeParamRef if needsNull(tp) => nullify(tp)
143147
// In all other cases, return the type unchanged.
144148
// In particular, if the type is a ConstantType, then we don't nullify it because it is the
145149
// type of a final non-nullable field.
146150
case _ => tp
147151
}
148152
}
149-
}
153+
}

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

+5-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ object NullOpsDecorator:
1414
* If this type isn't (syntactically) nullable, then returns the type unchanged.
1515
* The type will not be changed if explicit-nulls is not enabled.
1616
*/
17-
def stripNull(using Context): Type = {
17+
def stripNull(stripFlexibleTypes: Boolean = true)(using Context): Type = {
1818
def strip(tp: Type): Type =
1919
val tpWiden = tp.widenDealias
2020
val tpStripped = tpWiden match {
@@ -33,6 +33,9 @@ object NullOpsDecorator:
3333
if (tp1s ne tp1) && (tp2s ne tp2) then
3434
tp.derivedAndType(tp1s, tp2s)
3535
else tp
36+
case tp: FlexibleType =>
37+
val hi1 = strip(tp.hi)
38+
if stripFlexibleTypes then hi1 else tp.derivedFlexibleType(hi1)
3639
case tp @ TypeBounds(lo, hi) =>
3740
tp.derivedTypeBounds(strip(lo), strip(hi))
3841
case tp => tp
@@ -44,7 +47,7 @@ object NullOpsDecorator:
4447

4548
/** Is self (after widening and dealiasing) a type of the form `T | Null`? */
4649
def isNullableUnion(using Context): Boolean = {
47-
val stripped = self.stripNull
50+
val stripped = self.stripNull()
4851
stripped ne self
4952
}
5053
end extension

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

+4-4
Original file line numberDiff line numberDiff line change
@@ -562,11 +562,11 @@ class OrderingConstraint(private val boundsMap: ParamBounds,
562562
val underlying1 = recur(tp.underlying)
563563
if underlying1 ne tp.underlying then underlying1 else tp
564564
case CapturingType(parent, refs) =>
565-
val parent1 = recur(parent)
566-
if parent1 ne parent then tp.derivedCapturingType(parent1, refs) else tp
565+
tp.derivedCapturingType(recur(parent), refs)
566+
case tp: FlexibleType =>
567+
tp.derivedFlexibleType(recur(tp.hi))
567568
case tp: AnnotatedType =>
568-
val parent1 = recur(tp.parent)
569-
if parent1 ne tp.parent then tp.derivedAnnotatedType(parent1, tp.annot) else tp
569+
tp.derivedAnnotatedType(recur(tp.parent), tp.annot)
570570
case _ =>
571571
val tp1 = tp.dealiasKeepAnnots
572572
if tp1 ne tp then

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ trait PatternTypeConstrainer { self: TypeComparer =>
163163
}
164164
}
165165

166-
def dealiasDropNonmoduleRefs(tp: Type) = tp.dealias match {
166+
def dealiasDropNonmoduleRefs(tp: Type): Type = tp.dealias match {
167167
case tp: TermRef =>
168168
// we drop TermRefs that don't have a class symbol, as they can't
169169
// meaningfully participate in GADT reasoning and just get in the way.
@@ -172,6 +172,7 @@ trait PatternTypeConstrainer { self: TypeComparer =>
172172
// additional trait - argument-less enum cases desugar to vals.
173173
// See run/enum-Tree.scala.
174174
if tp.classSymbol.exists then tp else tp.info
175+
case tp: FlexibleType => dealiasDropNonmoduleRefs(tp.underlying)
175176
case tp => tp
176177
}
177178

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

+1
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ class TypeApplications(val self: Type) extends AnyVal {
541541
*/
542542
final def argInfos(using Context): List[Type] = self.stripped match
543543
case AppliedType(tycon, args) => args
544+
case tp: FlexibleType => tp.underlying.argInfos
544545
case _ => Nil
545546

546547
/** If this is an encoding of a function type, return its arguments, otherwise return Nil.

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

+6
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,8 @@ class TypeComparer(@constructorOnly initctx: Context) extends ConstraintHandling
864864
false
865865
}
866866
compareClassInfo
867+
case tp2: FlexibleType =>
868+
recur(tp1, tp2.lo)
867869
case _ =>
868870
fourthTry
869871
}
@@ -1059,6 +1061,8 @@ class TypeComparer(@constructorOnly initctx: Context) extends ConstraintHandling
10591061
case tp1: ExprType if ctx.phaseId > gettersPhase.id =>
10601062
// getters might have converted T to => T, need to compensate.
10611063
recur(tp1.widenExpr, tp2)
1064+
case tp1: FlexibleType =>
1065+
recur(tp1.hi, tp2)
10621066
case _ =>
10631067
false
10641068
}
@@ -3437,6 +3441,8 @@ class MatchReducer(initctx: Context) extends TypeComparer(initctx) {
34373441
isConcrete(tp1.underlying)
34383442
case tp1: AndOrType =>
34393443
isConcrete(tp1.tp1) && isConcrete(tp1.tp2)
3444+
case tp1: FlexibleType =>
3445+
isConcrete(tp1.hi)
34403446
case _ =>
34413447
val tp2 = tp1.stripped.stripLazyRef
34423448
(tp2 ne tp) && isConcrete(tp2)

0 commit comments

Comments
 (0)