dart-lang / native

Dart packages related to FFI and native assets bundling.
BSD 3-Clause "New" or "Revised" License
157 stars 44 forks source link

[jnigen] How to treat Java `null`? #1644

Open HosseinYousefi opened 1 month ago

HosseinYousefi commented 1 month ago

So far we decided not to make every Java object nullable in Dart because without additional annotations, every Java object can be nullable and the resulting code will be full of null-checks. However, we may even prefer this because then we can't forget to do if (nullableJObject.isNull) as the type system prevents us.

I recently made fromReference internal, so now the only way to pass null without hitting a lint is the verbose JObject.fromReference(jNullReference).as(Foo.type)!

wdyt @liamappelbe @dcharkes @stuartmorgan?

stuartmorgan commented 1 month ago

without additional annotations, every Java object can be nullable and the resulting code will be full of null-checks

From what I've seen in our own plugin development, annotating the nullability of everything public is best pratice (for interop with Kotlin, I assume). Given that annotations to solve this problem exist and are readily available I would think we would want to optimize for making better type safety easier, rather than optimizing for the ease of use of code that's not annotated.

Also, without annotations, the correct interpretation in a language like Dart is (as you said) that everything can be nullable. If everything could be nullable, then code being full of null checks is correct. So that doesn't actually seem like a problem to me (in the case of non-annotated code that can't be annotated for whatever reason).

HosseinYousefi commented 1 month ago

Sounds good. I can use the same annotations Kotlin uses to detect nullability by default: https://kotlinlang.org/docs/java-interop.html#nullability-annotations

and also add a way to include more annotations.

HosseinYousefi commented 2 weeks ago

One problem with the null-safe code generation is that the auto-detection of type classes no longer works for nullable types. For example

// BEFORE: user can do `foo(jstring)` and not pass `T` as it will be taken from bar
void foo($T bar, {JObjType<$T>? T}) {
  T ??= bar.$type; 
  // ...
}

// AFTER: user has to write `foo(jstring, T: JString.type)`
void foo($T? bar, {required JObjType<$T> T}) {
  // bar?.$type can be null
  // ...
}

The migration to extension types for all JObjects will eliminate the need for type classes for generics, so the regression is temporary: #1634.

HosseinYousefi commented 2 weeks ago

We can make T extends JObject nullable by writing T?, but we can't make T extends JObject? non-nullable in Dart. I'll add some docs and assertion in this case.

HosseinYousefi commented 1 week ago

What to do with generic parameters if a class does not have annotations? A class Foo<T>, can have a method T foo(). As T is not specified to non-nullable, in Dart we can create both a Foo<JString> and Foo<JString?>. In case of Foo<JString>, will the method foo return a JString or a JString??

Kotlin treats it as JString, but of course this is not a given, as in a code without any annotations, T and T? are the same. In this case if T = JString and foo wants to return null, we can throw a runtime error, forcing users to use Foo<JString?> instead.

What about type-classes? Currently we have a JString.type, we can add a JString.nullableType.

stuartmorgan commented 1 week ago

Kotlin treats it as JString, but of course this is not a given, as in a code without any annotations, T and T? are the same.

Does it treat it as non-nullable, or is it just usable silently as if it were because of type erasure?

I would expect generics to be treated like any other unannotated Java type, and be considered nullable in Dart, because they can in fact be null.

HosseinYousefi commented 1 week ago

I would expect generics to be treated like any other unannotated Java type, and be considered nullable in Dart, because they can in fact be null.

If the user constructs it passing T to be JString.type and not JString.nullableType, then they are basically implying that T will not be nullable. Otherwise there is no difference between Foo<JString> and Foo<JString?>.

Of course you will always get Foo<T?>? in the wild in unannotated code. This is only for when you explicitly pass a non-null type class to construct an object.

Does it treat it as non-nullable, or is it just usable silently as if it were because of type erasure?

It treats it as non-nullable.

HosseinYousefi commented 1 week ago

A real life example of the above is java.util.Map. If we create a map with using non-nullable types, without any nullability annotations, it's assumed that V put(K key, V value) will return V = JString and not V? even though it returns the previously associated value of key and null if there was no value associated with key. Same is true for get.

// Dart
final jmap = JMap.hash(JString.type, JString.type);
jmap.put('hello'.toJString(), 'world'.toJString()); // Error!

Kotlin simply knows about map:

// Kotlin
val map = java.util.HashMap<String, String>()
map.put("hello", "world") // Fine, it returns a `String?`

and map.values.first() will return String when the type is non-nullable in Kotlin.

What about a type that it doesn't know about? Like MyMap:

// Java
public class MyMap<K, V> {
  V put(K key, V value) {
    return null;
  }
}

In this case the return type of put is String!:

// Kotlin
val map = MyMap<String, String>()
val bar = map.put("hello", "world") // Returns `String!` which is basically `String? | String`.

What if MyMap has annotations on the type parameters but not on the return types?

// Java
public class MyMap<@NotNull K, @NotNull V> {
  V put(K key, V value) {
    return value;
  }
}

Now Kotlin assumes put returns String even though we have not explicitly specified @NotNull V put(...) on the method itself.

I'll go with the same logic for jnigen and since Dart doesn't have a T! we use T? for unannotated code.

HosseinYousefi commented 1 week ago

More importantly, if we explicitly specify V to be @Nullable, still put will have a return type of V and not V? in Kotlin:

// Java
public class MyMap<K, @Nullable V> {
  V put(K key, V value) {
    return value;
  }
}
val map = MyMap<String, String>()
val bar: String = map.put("hello", "world")

Basically the moment that we have any nullability annotation on the type param origin, not putting a nullability annotation on the spot means "the same nullability of the origin".