Make Null a subclass of AnyVal under -Yexplicit-nulls

I would like to take a step back and revisit the original problem statement:

As I see it, this is not a problem of the type system, but a property/problem of the diagram. (This point was also brought up by @MateuszKowalewski above, but not addressed in later posts.)

I agree that this diagram might not be optimal for beginning learners. But there really is no reason why a diagram meant for beginners should mention Null at all. Just don’t show it. Matchable is not a sealed trait, so leaving some subtypes out does not make the diagram less accurate or less honest.

Later, when it’s time to learn about the Null concept, we can show the full picture. Note that this does not invalidate anything that was shown before. Everything that was learned so far is still true.

So to me the question becomes “Where does Null have to be in the type system so that advanced learners can most easily understand it? What is its natural home?”

For several authors in this thread AnyVal seems not to be the natural home for Null. Even in the original post there is the telling phrase “If you have trouble wrapping your head around this, …” Also the arguments in favor of moving Null under AnyVal seem to be more about why this is not a problem instead of why AnyVal is a more natural home that Matchable. To me, all this indicates that the status quo with its pleasing symmetry w.r.t. AnyVal and AnyRef is easier to learn and understand. I guess this is also the reason why it was designed this way in the first place.

In conclusion, my Proposed Solution would be:
Don’t show Null in diagrams meant for beginners. Don’t change anything else.

5 Likes

Seconding @abosshard 's post — I had been contemplating posting something similar myself. Having the diagram be the centerpiece of an argument for a type system change feels odd to me.

Also (seconding Rex, I think), it doesn’t bother me for Null to be special, rather than being just another kind of AnyVal. Nulls are weird; I expect Null to be weird. Shoehorning Null under AnyVal doesn’t feel like a meaningful simplification to me. Null remains a special case either way.

In short, I’m not saying this is necessarily a bad change, but I don’t feel that the discussion so far has made a strong case for the change, either.

5 Likes

I’m not sure that the type system demands that behavior be isomorphic with respect to throwing exceptions, but let’s say so. I prefer that–it’s easier to reason about when there aren’t special cases.

null is already weird in the type system when it lives as an inhabited bottom type for AnyRef. I can’t call null.foo(). If Foo has a foo() method, though, I can call (null: Foo).foo() but not (null: AnyRef).foo(). If the point is to make null less weird, that’s good! But it kind of already violates LSP, depending on how one is allowed to construct property-tests. Nothing gets around the bottom type problem by being uninhabited. But it does make it less obvious to me that with explicit nulls we ought not cheat, even if I prefer it not-cheating.

Anyway, I agree with the analysis, if we’re committed to applying it. It’s cleaner that way.

But I also don’t like exceptions on core methods to be invited so warmly into AnyVal. Without any argument for why it belongs there aside from making prettier diagrams (with the counterargument being: it throws exceptions by design on core Any methods, which is dangerous), I’d tend to fix the diagram, not the type hierarchy.

The other magic I demand of null is that I can throw it.

scala-cli repl --server=false -S 3.8.2
Welcome to Scala 3.8.2 (25, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.

scala> throw null
java.lang.NullPointerException: Cannot throw exception because "null" is null
  ... 32 elided

but

scala-cli repl --server=false -S 3.8.2 -Yexplicit-nulls
Welcome to Scala 3.8.2 (25, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.

scala> throw null
-- [E007] Type Mismatch Error: -------------------------------------------------
1 |throw null
  |      ^^^^
  |Found:    Null
  |Required: Throwable
  |Note that implicit conversions were not tried because the result of an implicit conversion
  |must be more specific than Throwable
  |
  | longer explanation available when compiling with `-explain`
1 error found

Maybe this tells us that the true identity of null is java.lang.NullPointerException, because you tried to throw it, and it did throw, and this is what resulted.

1 Like

which it didn’t do, as evident by the error :slight_smile: (it did not throw it)

null takes as much space as any other reference. in modern (i.e. 64-bit) systems it can take 4 or 8 bytes, depending whether references are compressed or not. by measuring occupied space it’s visible that null is made to fit in place of any other reference.

null is a reference that points to nowhere. member selection on a null (e.g. invoking null.toString()) fails with null pointer exception because you can’t access an object referenced by null.

simultaneously, null itself and all other references are values as e.g. in java everything is passed by value. how can something be passed by value it it’s not a value itself? reference is a value that is used mostly to access the referenced object.

the documentation of scala.Any.== is currently quite loose, imho. Any :

final def ==(x$0: Any): Boolean

Test two objects for equality. The expression x == that is equivalent to if (x eq null) that eq null else x.equals(that).

hard to rewrite any1 == any2 if the eq operator is not defined on them.

also i don’t think that skipping explanation of what null is is something beneficial overall. if someone is a curious and creative beginner (as we all had been in the past, didn’t we?) then making unreasonably dangerous code is something that’s statistically very probable. when learning programming there are so many new things to think about that keeping oneself to strict rules about safety is too much for a beginner and it’s not hard to make nulls pop up here and there. for example Scastie - An interactive playground for Scala. :

trait Printer {
  def value: String

  println(value) // put more elaborate logic here instead
}

class Greeter extends Printer {
  val value: String = "hello, world!" // put more elaborate logic here instead
}

new Greeter() // prints: null

a skilled programmer avoids such things intuitively as experience tells that caring about initialization order is needed to avoid unnecessary risk. a beginner, however, won’t find such code suspicious but rather concise and to the point.

OK, I’m an idiot.

I misunderstood this whole thing for some reason as placing Null under AnyVal but above other value types.

If Null becomes a value type that as such doesn’t look like it would make much trouble.

Still I don’t think it’s justified to change the status quo as for one null is inherently weird, so it can have it’s weird spot in the type system, and maybe this is even more honest to the reality how null actually behaves.

Trying to make it “safer” so it doesn’t throw can’t be done as this would in fact defeat to have it at all. It needs to be really the naked null value; that’s the whole point.

But because Null is weird pretending that it’s just another regular value feels somehow odd—even if it does not lead to any contradictions in the type system.

Nevertheless I’m still of the opinion that if the learning experience is the main concern that diagram and its docs need an update, not the type system.

1 Like

I feel the same way. Null represents the absence of a regular value, so viewing it as a regular value itself would lead to a seeming paradox: its presence would seem to imply its own absence.

This view makes me think it is pedagogically important to move Null to AnyVal.

Denying that null is a value (that one might expect to be represented by a certain string of bits) is like denying the same for Unit value because one is accustomed to void.

It’s not about denying that null is represented by a certain string of bits, which is how it might be implemented, but keeping it away from AnyVal, which in the status quo is the root class of all value types, whose values are regular.

As you know there are already many situations where a custom AnyVal could be null such as an array index or uninitialised field

Unfortunately. But the goal is to make these matters better (e.g. with Safe Initialization) not worse by diluting the mental model of what AnyVal essentially represents, right?

1 Like

This is true, a common gotcha, which is why many of us wanted “explicit nulls” in the first place.

My preference would be the opposite of this proposal … this shouldn’t be true either:
Null <: Any, or in other words the root of everything should be Any | Null.

Any should NOT be the root, because Null isn’t an object, and thus misbehaves when it comes to any of the methods defined on Any. Null cannot be Any because that still violates Liskov’s substitution principle, unless you change what Any means.

For example, this code should trigger compile-time errors under -Yexplicit-nulls, but it currently does not:

scala> null.equals(1)
java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because "null" is null
  ... 32 elided

scala> val x: Int | Null = null
val x: Int | Null = null

scala> x.equals(1)
java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because the return value of "rs$line$2$.x()" is null
  ... 32 elided

And if this happens, then what are “explicit nulls” good for anyway?

And I don’t know how Java’s future additions to the language will unfold, however, what I said is true for both:

  • Kotlin, which has Any vs Any? (e.g., Any | Null) — the above code in Kotlin does generate compile time errors.
  • Java’s JSpecify, Object being non-nullable, so you need @Nullable Object, this being a PITA in code, because type parameters are by default considered not nullable, since <T> is considered to be <T extends Object>, therefore the code gets infected with declarations like <T extends @Nullable Object>. So even in Java’s solutions, Null is not Object.

So for me Null <: AnyVal is wrong because Null <: Any is already wrong as long as Any =:= java.lang.Object.

To showcase Kotlin, the sample above does yield compiler errors and in my opinion, when trying to model “explicit nulls”, Scala should be inspired by the languages that have been doing it for a while:

5 Likes

I already (wrongly) added a comment on the GitHub PR: RFC: Make `Null` a subclass of `AnyVal` under `-Yexplicit-nulls`. by sjrd · Pull Request #25393 · scala/scala3 · GitHub

To that I’ll add, as mentioned in my other comment, that Null should NOT be AnyVal, firstly, because Null should NOT be Any. Null <: Any is a problem in the first place, as it still violates LSP, and there’s prior art in Kotlin and in Java’s JSpecify that decided against Null <: Any.

If Null <: Any, it defeats the purpose of having explicit nulls in the first place. Here’s an example:

class Queue[T]

Let’s say that this Queue implementation uses null under the hood for special purposes, therefore T cannot be null. This is, for example, the case for the implementations in JCTools. So how do you restrict T to be non-nullable?

Well, we could’ve had something like:

class Queue[T <: Any]

But, currently we can’t, can we? And again, this isn’t an issue for Kotlin, or for Java’s JSpecify.

Null <: AnyVal is doubling down on this design mistake, making it worse, IMO.

1 Like

Using the current semantics of AnyVal and AnyRef (under -Yexplicit-nulls), we can express it like this:

type NotNull = AnyVal | AnyRef

class Queue[T <: NotNull]
1 Like

Something like class Top with no method then Null <: Top and Any <: Top

Going in this direction, would Top be defined as a sealed trait with Any and Null as its only subtypes?

ahh, the mighty non-null type achieved without the elusive type negation. looks interesting and potentially useful.

When reading the orginal proposal, this was my exact thought

And so I was mostly in the “I don’t really have an opinion on this” camp

But I must say this is a pretty compelling argument:

As for the opposite proposal of making null !<: Any, I again don’t really have an opinion on it.
It feels compelling given nulls should always live on the interface with other languages.
But I’m a bit afraid it would break a lot of things