Definitely non-nullable types
dzharkov opened this issue · 35 comments
The goal of this proposal is to allow explicitly declare definitely not-nullable type
First version (obsolete): https://github.com/Kotlin/KEEP/blob/7b998efaf70cc8d783a57af14b8701886089a5fe/proposals/definitely-not-nullable-types.md
Second version (11 Aug 2021): https://github.com/Kotlin/KEEP/blob/c72601cf35c1e95a541bb4b230edb474a6d1d1a8/proposals/definitely-non-nullable-types.md
Specific comments may be left here or at the #269
My first impression is that the syntax feels clunky, and is very specific. Could this be tackled with a more general solution?
Taking the original Java use-case:
<T> void put(@NotNull T t);
Could this be solved by introducing some sort of refined/non-parameter generic types?
fun <T> put(t: TNotNull) where TNotNull : T, TNotNull : Any
// Needs to be defined somehow ^^^^^^^^ ^^^ "Type parameter cannot have any other bounds
// if it's bounded by another type parameter"
Or allowing @NotNull
to be used the same way in Kotlin?
Using contracts also comes to mind, but that's a bit different semantically. It also (I think?) doesn't change the signature in the same way.
I am not sure about the importance of the use case, but there is a problem with the syntax. Double bang (!!
) is in most cases a synonym to code-smell and was introduced exactly for that. Using in for perfectly normal type is going to rise cognitive dissonance.
Also, have you considered a more universal way - type intersections. T & Any
is longer, but it does not seem to be a very frequent use-case.
@BenWoodworth Thanks for the suggestions!
My first impression is that the syntax feels clunky, and is very specific. Could this be tackled with a more general solution?
Yes, we may just introduce (at least partially) intersection types for T & Any
Could this be solved by introducing some sort of refined/non-parameter generic types?
fun <T> put(t: TNotNull) where TNotNull : T, TNotNull : Any // Needs to be defined somehow ^^^^^^^^ ^^^ "Type parameter cannot have any other bounds // if it's bounded by another type parameter"
Such a type parameter would make a signature for put
effectively different
Or allowing
@NotNull
to be used the same way in Kotlin?
We've always tried to avoid bringing Java-specific nullability annotations to Kotlin, since there's already a syntax for nullability.
Also, it would require having those annotations in stdlib or some other artifact shipped like a standard library.
I am not sure about the importance of the use case, but there is a problem with the syntax. Double bang (
!!
) is in most cases a synonym to code-smell and was introduced exactly for that. Using in for perfectly normal type is going to rise cognitive dissonance.
Thanks! That is a bit of dissonance we are afraid of. At the same time, one might have an intuition that it's not very surprising that expressions having a form of t!!
are belonging to the type family T!!
. But I see your concern.
@dzharkov It is problematic from the teaching perspective. We are teaching that double-bang must be avoided and should be used only under very specific conditions. But introducing another similar syntax where recommendations are completely different, would bring a lot of confusion and significantly complicate the understanding. For new people, the similar syntax should have a similar meaning and similar usage practices if want it to keep being simple.
I fully agree with @altavir.
I perceive the !!
as a cast, it looks strange on a type.
var s: String?
println(s!!) // println(s as String)
Honestly, I like to understand the problem better, I don't feel it as a problem.
The example fun <T> elvisLike(x: T, y: T!!): T!!
can be already implemented as fun <T : Any> elvisLike(x: T?, y: T): T
@fvasco elvisLike
is not a use case, but it's more like an example of its semantics.
The only real use case we've got so far is the impossibility to override/implement some annotated Java API.
@altavir @fvasco This is not a contradiction to me. I would say "double-bang types" should also be avoided and only be used under very specific conditions. The KEEP states there are special conditions, where they are required for Java interoperability. The ugly double-bang would help guiding people to derive 'T' from non-nullable type and use 'T?' (as fvasco pointed out).
If this proposal is implemented, we're going to relax some stdlib function signatures by removing T : Any
constraint from them and using T!!
(or T & Any
) type instead. For example:
fun <T : Any> Sequence<T?>.filterNotNull(): Sequence<T>
would become
fun <T> Sequence<T>.filterNotNull(): Sequence<T!!>
Is there currently any difference at all between the following?
fun <T : CharSequence?> foo(t: T?, tn: T)
fun <T : CharSequence?> foo(t: T, tn: T?)
fun <T : CharSequence?> foo(t: T?, tn: T?)
fun <T : CharSequence?> foo(t: T, tn: T)
fun <T : CharSequence?> foo(t: T?, tn: T)
fun <T : CharSequence?> foo(t: T, tn: T?)
fun <T : CharSequence?> foo(t: T?, tn: T?)
fun <T : CharSequence?> foo(t: T, tn: T)
Yes, of course. Call foo<String>(null, "A")
and you will see.
If Kotlin had type intersections already, T & Any
would make sense.
But it has not (yet ;)). And therefore for regular kotlin users this syntax would look obscure. So it did to me.
Of course Kotlin already have such intersections in inferred type hints so from this point of view it can worked out.
It would be much better to have just T!
as a companion of T?
. But as its mentioned in the KEEP its already occupied by flexible-types. But can't we find any other way to note it?
What if make T
always 'definitely not-null' and therefore break BC, unfortunately.
But It would make Kotlin generic and non-generic type notations consistent.
// In both functions these statements would be correct
// [a] is definitely not nullable
// [b] is definitely not nullable
// [c] is nullable list of definitely not nullable elements
fun foo(a: Int, b: Int?, c: List<Int>?)
fun <T> bar(a: T, b: T?, c: List<T>?)
Flexible types would still work as currently. As far as I can see.
Second option is to change notation of flexible type T!
to T!!
. So T!!
will now also express the "danger" of t!!
in expressions. That using T as non-null is possible but dangerous. And T!
will mean 'definitely not nullable'
What do you think? I did you discuss costs of breaking changes in this area?
Or allowing @NotNull to be used the same way in Kotlin?
We've always tried to avoid bringing Java-specific nullability annotations to Kotlin, since there's already a syntax for nullability.
Also, it would require having those annotations in stdlib or some other artifact shipped like a standard library.
The only real use case we've got so far is the impossibility to override/implement some annotated Java API.
@dzharkov If there aren't any cases where this would be necessary outside of Java interop, it seems like it would be much simpler to add another JVM-specific annotation. I don't understand the concern of shipping it in the stdlib considering that the common namespace is already polluted with kotlin.jvm.*
annotations.
Thank you all for your suggestions!
We've updated the proposal
The main change is that we moved from confusing T!!
syntax to a limited version of intersection types – T & Any
.
It's likely that at some point we'll have full support for intersection types and then it would be just a special case that doesn't deserve special syntax because it seems that the only real-world use-case we have by now is overrides of annotated Java.
On the concern that it might be obscure for newcomers:
- It's unlikely that a lot of people would meet such types frequently considering it's rather rare
- One still may google
T & Any
and find something about it and any other syntax will hardly be very clear (at least we haven't seen such options yet)
Second option is to change notation of flexible type T! to T!!. So T!! will now also express the "danger" of t!! in expressions. That using T as non-null is possible but dangerous. And T! will mean 'definitely not nullable'
Changing notation for flexible types just because of a very rare feature looks too hard for me
@dzharkov If there aren't any cases where this would be necessary outside of Java interop, it seems like it would be much simpler to add another JVM-specific annotation. I don't understand the concern of shipping it in the stdlib considering that the common namespace is already polluted with
kotlin.jvm.*
annotations.
Agree, one might say it's already polluted, but we still do our best to avoid making it worth (at least for rarely used features)
Is there a possibility maybe to put a (very rudimentary and rough) version of intersection types behind an experimental flag? Because IIRC intersection types are already in the compiler and so having a rough user-facing version of them behind a flag shouldn't be too difficult. Just a little something that we can experiment with for the time being
I'm definitely hyped for intersection types! Syntax sugar like T!!
is something I don't care much about. Myself, I am not convinced we need T!!
at all, as we can use : Any
bound on T
and just use T?
where applicable. Seems more paradigmatic for me. I haven't seen so far use case for T!!
, that would convince me that:
a) Is needed.
b) Is cleaner than puting : Any
bound on T
and using T?
where applicable.
Except of interop issue mentioned in the proposal. (Unless we would always treat Java generic parameters as not null, and infer T?
as type where not annotated @NotNull
on annotated @Nullable
) But I would prefer T & Any
without support for syntax sugar, to avoid encouraging such constructs in plain Kotlin code.
For the record, while I understand the underpinnings of the particular syntax chosen (T & Any
), I think it has a serious issue, i.e. it is not sufficiently self-explanatory. A person looking at it doesn’t immediately see the intent (“make T not-null”), but sees what appears to be a triviality (“Any is the top type, so T intersected with Any must be T”). Yes, the top type is Any?
, but most users don’t realize this immediately. If we called the Any class something like “NotNullReference”, it would have been all different, but for better or worse we called it Any
which is very misleading in this context. I would argue that even an annotation like @MakeNotNull
, though obviously clunky and ad hoc, would serve better in this role.
I agree, but is still better than T!! and plays well with hypothetic real intersection types in the future.
Let me offer a use case that may motivate the ! as a complement to ? on types.
fun <T : Any> lookup(type: Class<T>) : Deserializer<T> { ... }
fun <T> Deserializer<T>.optional(): Deserializer<T?> {
val parent = this
return object : Deserializer<T?> {
override fun deserialize(data: ByteArray?): T? {
return data?.let {
parent.deserialize(data)
}
}
}
}
inline fun <reified T> deserializer() : Deserializer<T> {
return (null is T) ? lookup(T::class.java).optional() : lookup(T::class.java)
}
If you think about it, T::class.java isn't type Class<T>
. It's Class<T!>
Rather than being an assertion that T is not a nullable type, T! could 1) express that a type is the non-nullable version of a potentially nullable type, and 2) act as an operator on a reified type that outputs the non-nullable version. #2 would allow lookup(T!::class.java)
above.
In this way, it would be an effective complement to T?.
There seems to be only one use-case for this, implementing a Java interface like the one in the proposal:
public interface JBox<T> { // I'm assuming putting the type parameter on the method was a typo
void put(@NotNull T t);
}
This feature is only necessary for Kotlin/JVM, so it should be done with @Jvm*
annotations like previous JVM-specific features—this was suggested earlier in #268 (comment). In pure Kotlin, such an interface can be made today without any new syntax:
public interface Box<T : Any> {
fun put(t: T)
}
// Better example which uses both nullable and non-nullable in the signature. Imagine this function returns the previously held value, or null on the first call.
public interface Box<T : Any> {
fun set(t: T): T?
}
@roxton Kotlin doesn't have a ternary operator ?:
. Is this what you were going for?
fun interface Deserializer<T> {
fun deserialize(data: ByteArray?): T?
}
fun <T : Any> lookup(type: KClass<T>): Deserializer<T> = TODO()
fun <T> Deserializer<T>.optional(): Deserializer<T?> = Deserializer({ it?.let(this@optional::deserialize) })
inline fun <reified T> deserializer(): Deserializer<T> = if (null is T) lookup(T::class).optional() else lookup(T::class)
// call-site:
val d = deserializer<User>() // d is Deserializer<User>
val d1 = deserializer<User?>() // d1 is Deserializer<User?>, which is a wrapper over a Deserializer<User>
(TIL null is T
is valid for reified type parameters.)
If you think about it, T::class.java isn't type
Class<T>
. It'sClass<T!>
Not sure where this came from. The type of T::class
is not KClass<T!>
, it's KClass<T>
, and the java
extension property preserves the nullability. In this case T : Any?
from the function signature, meaning it won't compile because the signature for lookup
can't be inferred, not even in the null !is T
branch (there's no smart cast to T : Any
).
Sure, maybe some form of intersection types will let this compile, or an annotation or whatever. But because the function is going to be inlined anyway, why not just do this:
inline fun <reified T : Any> deserializerFor(): Deserializer<T> = lookup(T::class)
inline fun <reified T : Any> deserializerForNullable(): Deserializer<T?> = lookup(T::class).optional()
Or only have the first function and add .optional()
at the call-site.
Is the "& Any" becoming official?
Why choose this weird syntax? Is it from another language? How do you read it?
Can anyone please explain the logic behind choosing it? I've never seen such a thing and maybe by reading the logic of it, I will remember to use it.
Is the "& Any" becoming official? Why choose this weird syntax? Is it from another language? How do you read it? Can anyone please explain the logic behind choosing it? I've never seen such a thing and maybe by reading the logic of it, I will remember to use it.
The &
indicates an intersection. Cloneable & Serializable
are all types that are Cloneable
and Serializable
.
Any
covers all non-nullable types (contrary to Any?
, which includes nullable types), so if a type T
includes null, then T & Any
will be T
without null.
Kotlin currently has limited support for intersection types, but wider support is in discussion.
It's borrowed from Java, which probably borrowed it from an older language but that was before my time so IDK.
@quickstep24 I see now the point in this.
Still seems weird.
Speaking about Any?
, doesn't it mean that I can even use this useless thing : T & Any?
? . It means it's nullable, yet everything is already always nullable by default (like on Java), no?
@YoshiRulz Where do you see it in Java? In the code that caused it to appear (Glide in my case) I don't see in Java what has caused it...
Maybe you mean from a relatively new Java version (I use on Android, so Java version is very much behind, sadly)?
Bounds on type parameters can include intersection types, and it's certainly not a new feature. In Kotlin you'd use multiple where
clauses.
@YoshiRulz So how does a non-null type appears in Java for the generic type ("T") ?
Looking at the code of Glide, I didn't see there anything special that I don't already know of.
This is what I see there:
public abstract class CustomTarget<T> implements Target<T> {
Where "Target" is as such:
public interface Target<R> extends LifecycleListener {
void onResourceReady(@NonNull R resource, @Nullable Transition<? super R> transition);
I don't see R or T marked as "always not null". It's just that currently, all functions (I've shown only what's relevant to the function I use) have them non-null.
I think there's been a slight misunderstanding; I never meant to imply that this feature as borrowed from Java, only that the &
syntax was. My understanding is that Java's type system isn't concerned with null at all, hence the annotations.
@YoshiRulz Oh ok.
So how does the IDE decides that I should use it from the Java code? Not by actual declaration, but actually by checking the annotations on Java, seeing that all usages mean it's non-null?
Does it do it for Kotlin too (in case I extend a class that doesn't have the & Any
yet all usages are non-null) ?
¯\_(ツ)_/¯
@YoshiRulz OK guys thank you for your patience and for your time.