Kotlin / KEEP

Kotlin Evolution and Enhancement Process
Apache License 2.0
3.42k stars 362 forks source link

Definitely non-nullable types #268

Open dzharkov opened 3 years ago

dzharkov commented 3 years ago

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

BenWoodworth commented 3 years ago

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.

altavir commented 3 years ago

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.

dzharkov commented 3 years ago

@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.

dzharkov commented 3 years ago

@altavir

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.

altavir commented 3 years ago

@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.

fvasco commented 3 years ago

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

dzharkov commented 3 years ago

@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.

quickstep24 commented 3 years ago

@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).

ilya-g commented 3 years ago

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!!>

trevorhackman commented 3 years ago

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)
quickstep24 commented 3 years ago

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.

antohaby commented 3 years ago

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?

EddieRingle commented 3 years ago

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.

dzharkov commented 3 years ago

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:

dzharkov commented 3 years ago

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 commented 3 years ago

@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)

kyay10 commented 3 years ago

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

NiematojakTomasz commented 3 years ago

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.

abreslav commented 3 years ago

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.

altavir commented 3 years ago

I agree, but is still better than T!! and plays well with hypothetic real intersection types in the future.

roxton commented 2 years ago

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?.

YoshiRulz commented 2 years ago

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 https://github.com/Kotlin/KEEP/issues/268#issuecomment-890073523. 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's Class<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.

AndroidDeveloperLB commented 1 year ago

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.

quickstep24 commented 1 year ago

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.

YoshiRulz commented 1 year ago

It's borrowed from Java, which probably borrowed it from an older language but that was before my time so IDK.

AndroidDeveloperLB commented 1 year ago

@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)?

YoshiRulz commented 1 year ago

Bounds on type parameters can include intersection types, and it's certainly not a new feature. In Kotlin you'd use multiple where clauses.

AndroidDeveloperLB commented 1 year ago

@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.

YoshiRulz commented 1 year ago

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.

AndroidDeveloperLB commented 1 year ago

@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 commented 1 year ago

¯\_(ツ)_/¯

AndroidDeveloperLB commented 1 year ago

@YoshiRulz OK guys thank you for your patience and for your time.

BreimerR commented 1 year ago

image In my assumption if case val res: String? is a warning shouldn't getOrDefault<String?> be a warning as well?

cccccccmcho commented 1 month ago

Is this feature enforced since kotlin 1.9 and later? I'm getting an error in compile since upgrading from 1.8

YoshiRulz commented 1 month ago

https://kotlinlang.org/docs/whatsnew17.html#stable-definitely-non-nullable-types