Proposal: Fixing null.methodOfAny() under explicit-nulls

This is a follow-up to Make Null a subclass of AnyVal under -Yexplicit-nulls - #56 by sjrd . A particular concern that was brought up is that, even under explicit-nulls, the methods of Any are still null-unsafe.

To be clear: all of this thread is under the context of -Yexplicit-nulls. I do not propose to change anything in the currently standard Scala.

Null <: Any in Scala. This is unavoidable, because, by definition, Any is the supertype of all proper types. Proper types are the types of values, as opposed to higher-kinded types, which are the types of other types.

That poses a problem, because several methods available on Any are not supported by null. They throw a NullPointerException. The most obvious example is toString(). Here is a complete table of the methods we are allowed to call on x: Any:

Method of Any Behavior for null
==, != :white_check_mark: Safe (null == null and to nothing else)
## :white_check_mark: returns 0
isInstanceOf[X] :white_check_mark: returns false for all X
asInstanceOf[X] :warning: let’s say: not more unsafe than on other values
hashCode() :cross_mark: throws NPE
equals(y) :cross_mark: throws NPE
toString() :cross_mark: throws NPE
getClass() :cross_mark: throws NPE
nn :white_check_mark: throws NPE, but that’s the point

x.hashCode() and x.equals(y) have had good alternatives forever: use x.## and x == y instead.

x.toString() sort of has alternatives: s"$x", "" + x, String.valueOf(x) all return the string "null" when x == null. They’re not very nice, though.

x.getClass() has no alternative. You need a type test.

Proposal

I propose the following changes:

  • Deprecate Any.hashCode() and Any.equals() altogether. Override them as non-deprecated in AnyRef (aka jl.Object)
  • Change the behavior of Any.toString() and Any.getClass():
    • null.toString() should return the string "null"
    • null.getClass() should return classOf[Null]

We can implement the changes using additional dispatch introduced by the compiler. For x: T with Null <: T, we will compile

Source Rewritten
x.toString() String.valueOf(x)
x.getClass() if (x ne null) x.asInstanceOf[AnyRef].getClass() else classOf[Null]

This way, it will be safe to call all (non-deprecated) methods of Any on null. Problem solved.

Prior art

I am told Kotlin allows to call x.toString() when x: Any?, and that it returns "null". Scala’s Any type is equivalent to Kotlin’s Any?: they each are the supertype of all proper types in their respective type systems.

For getClass(), it’s a bit convoluted, but since we can write classOf[Null] in source and do get an actual instance of jl.Class, I believe that is the natural choice.

WDYT?

9 Likes

What is supposed to happen during compiletime/runtime with a code under the explicit null flag that calls a library that did not turn on the flag and vice-versa?

The semantics of any particular call will depend on the options used to compile that call.

  • explicit-nulls calls non-explicit-nulls calls toString() → NPE.
  • non-explicit-nulls calls explicit-nulls calls toString() → "null".

Why deprecate hashCode and equal and specializing toStringand getClass? Why not deprecate all 4 and add alternatives for toString and getClass? Or specialize all 4?

Maybe, because the specialization induces some overhead (if not JIT optimized), so its better to explicitly use the alternatives?
I guess deprecating toString and getClass on AnyVal would be quite inconvenient, even if an alternative exists.

Mostly asking to poke at the design, the proposed variant does not seem worse to me.

Because ##, ==, toString and getClass are already the idiomatic things to use today. So every code that’s already doing the right thing won’t have to change.

And the other way round? Why not also make hashcode and equals work?

Because they do not respect cooperative equality. And they must be allowed to stay that way, because when we override them in user classes we must not deal with cooperative equality. That’s why ## exists in the first place.