Letting the nulls flow (Was: Exhaustiveness)

forax at univ-mlv.fr forax at univ-mlv.fr
Sun Aug 23 20:12:53 UTC 2020


----- Mail original -----
> De: "Brian Goetz" <brian.goetz at oracle.com>
> À: "Remi Forax" <forax at univ-mlv.fr>
> Cc: "Guy Steele" <guy.steele at oracle.com>, "Tagir Valeev" <amaembo at gmail.com>, "amber-spec-experts"
> <amber-spec-experts at openjdk.java.net>
> Envoyé: Samedi 22 Août 2020 19:14:15
> Objet: Letting the nulls flow (Was: Exhaustiveness)

> Breaking into a separate thread.   I hope we can put this one to bed
> once and for all.
> 
>> I'm not hostile to that view, but may i ask an honest question, why
>> this semantics is better ?
>> Do you have examples where it makes sense to let the null to slip
>> through the statement switch ? Because as i can see why being null
>> hostile is a good default, it follows the motos "blow early, blow
>> often" or "in case of doubt throws".
> 
> Charitably, I think this approach is borne of a belief that, if we keep
> the nulls out by posting sentries at the door, we can live an interior
> life unfettered by stray nulls.  But I think it is also time to
> recognize that this approach to "block the nulls at the door" (a)
> doesn't actually work, (b) creates sharp edges when the doors move
> (which they do, though refactoring), and (c) pushes the problems elsewhere.
> 
> (To illustrate (c), just look at the conversation about nulls in
> patterns and switch we are having right now!  We all came to this
> exercise thinking "switch is null-hostile, that's how it's always been,
> that's how it must be", and are contorting ourselves to try to come up
> with a consistent explanation.   But, if we look deeper, we see that
> switch is *only accidentally* null-hostile, based on some highly
> contextual decisions that were made when adding enum and autoboxing in
> Java 5.  I'll talk more about that decision in a moment, but my point
> right now is that we are doing a _lot_ of work to try to be consistent
> with an arbitrary decision that was made in the past, in a specific and
> limited context, and probably not with the greatest care.  Truly today's
> problems come from yesterdays "solutions."  If we weren't careful, an
> accidental decision about nulls in enum switch almost polluted the
> semantics of pattern matching!  That would be terrible!  So let's stop
> doing that, and let's stop creating new ways for our tomorrow's selves
> to be painted into a corner.)
> 
> 
> As background, I'll observe that every time a new context comes up,
> someone suggests "we should make it null-hostile."  (Closely related: we
> should make that new kind of variable immutable.)  And, nearly every
> time, this ends up being the wrong choice.  This happened with Streams;
> when we first wrestled with nulls in streams, someone pushed for "Just
> have streams throw on null elements."  But this would have been
> terrible; it would have meant that calculations on null-friendly
> domains, that were prepared to engage null directly, simply could not
> use streams in the obvious way; calculations like:
> 
>     Stream.of(arrayOfStuff)
>                 .map(Stuff::methodThatMightReturnNull)
>                 .filter(x -> x != null)
>                 .map(Stuff::doSomething)
>                 .collect(toList())

Each time i see a map(...).filter(x -> x != null), i see a code that screams flatMap() or the new mapMulti()

  .flatMap(stuff -> Optional.ofNullable(stuff.methodThatMightReturnNull()).stream())

  .mapMulti((stuff, consumer) -> {
   var result = stuff.methodThatMightReturnNull();
   if (result != null) {
     consumer(result);
   }
  }) 

Note that in both case, there is no null that flows through the Stream anymore.

I think the facts that
  - there was already loops that declare variables that can be null,
  - ArrayList or HashMap accept null and
  - we want refactoring from a loop to a stream and vice-versa
sealed the deal.

> 
> would not be directly expressible, because we would have already NPEed.
> Sure, there are workarounds, but for what?  Out of a naive hope that, if
> we inject enough null checks, no one will ever have to deal with null?
> Out of irrational hatred for nulls?  Nothing good comes from either of
> these motivations.
> 
> But, this episode wasn't over.  It was then suggested "OK, we can't NPE,
> but how about we filter the nulls?"  Which would have been worse.  It
> would mean that, for example, doing a map+toArray on an array might not
> have the same size as the initial array -- which would violate what
> should be a pretty rock-solid intuition.  It would kill all the
> pre-sized-array optimizations.  It would mean `zip` would have no useful
> semantics.  Etc etc.

I agree, ignoring null is never the right way to do something
and

<snark comment>
which zip, we finally have one !
</snark>

> 
> In the end, we came to the right answer for streams, which is "let the
> nulls flow".   And this is was the right choice because Streams is
> general-purpose plumbing.  The "blow early" bias is about guarding the
> gates, and thereby hopefully keeping the nulls from getting into the
> house and having wild null parties at our expense. And this works when
> the gates are few, fixed, and well marked.  But if your language
> exhibits any compositional mechanisms (which is our best tool), then
> what was the front door soon becomes the middle of the hallway after a
> trivial refactoring -- which means that no refactorings are really
> trivial.  Oof.

I agree but at the same time, if we talk about refactoring, a switch on type is a refactoring from a cascade of if ... instanceof and instanceof while not null hostile, doesn't not match null.

> 
> We already went through a good example recently where it would be
> foolish to try to exclude null (and yet we tried anyway) --
> deconstruction patterns.  If a constructor
> 
>     new Foo(x)
> 
> can accept null, then a deconstructor
> 
>     case Foo(var x)
> 
> should dutifully serve up that null.  The guard-the-gates brigade tried
> valiantly to put up new gates at each deconstructor, but that would have
> been a foolish place to put such a boundary.  

I again, i fully agree with you Foo(var) as to accept null, otherwise it's not a total pattern too.
Again using the analogy of a cascade of if ... instanceof, Foo(var) is equivalent to "else" thus should accept null.

[...]

> 
> Now, switch.  As I mentioned, I think we're here mostly because we are
> perpetuating the null biases of the past.  In Java 1.0, switches were
> only over primitives, so there was no question about nulls.  In Java 5,
> we added two new reference-typed switch targets: enums and boxes.  I
> wasn't in the room when that decision was made, but I can imagine how it
> went: Java 5 was a *very* full release, and under dramatic pressure to
> get out the door.  The discussion came up about nulls, maybe someone
> even suggested `case null` back then.  And I'm sure the answer was some
> form of "null enums and primitive boxes are almost always bugs, let's
> not bend over backwards and add new complexity to the language (case
> null) just to accomodate this bug, let's just throw NPE."
> 
> And, given how limited switch was, and the special characteristics of
> enums and boxes, this was probably a pragmatic decision, but I think we
> lost sight of the subtleties of the context.  It is almost certainly
> right that 99.999% of the time, a null enum or box is a bug.  But this
> is emphatically not true when we broaden the type to Object.  Since the
> context and conditions change, the decision should be revisited before
> copying it to other contexts.
> 
> In Java 7, when we added switching on strings, I do remember the
> discussion about nulls; it was mostly about "well, there's a precedent,
> and it's not worth breaking the precedent even if null strings are more
> common than null Integers, and besides, the mandate of Project Coin is
> very limited, and `case null` would probably be out of scope."  While
> this may have again been a pragmatic choice at the time given the
> constraints, it further set us down a slippery slope where the
> assumption that "switches always throw null" is set in concrete.  But
> this assumption is not founded on solid ground.
> 
> So, the better way to approach this is to imagine Java had no switch,
> and we were adding a general switch today.  Would we really be
> advocating so hard for "Oooh, another door we can guard, let's stick it
> to the nulls there too"?  (And, even if we were tempted to, should we?)
> 
> The plain fact is that we got away with null-hostility in the first
> three forms of reference types in switch because switch (at the time)
> was such a weak and non-compositional mechanism, and there are darn few
> things it can actually do well.  But, if we were designing a
> general-purpose switch, with rich labels and enhanced control flow
> (e.g., guards) as we are today, where we envisioned refactoring between
> switches on nested patterns and patterns with nested switches, this
> would be more like a general plumbing mechanism, like streams, and when
> plumbing has an opinion about the nulls, frantic calls to the plumber
> are not far behind.  The nulls must flow unimpeded, because otherwise,
> we create new anomalies and blockages like the streams examples I gave
> earlier and refactoring surprises. And having these anomalies doesn't
> really make life any better for the users -- it actually makes
> everything just less predictable, because it means simple refactorings
> are not simple -- and in a way that is very easy to forget about.
> 
> If we really could keep the nulls out at the front gate, and thus define
> a clear null-free domain to work in, then I would be far more
> sympathetic to the calls of "new gates, new guards!"  But the gates
> approach just doesn't work, and we have ample evidence of this.  And the
> richer and more compositional we make the language, the more sharp edges
> this creates, because old interiors become new gates.
> 
> So, back to the case at hand (though we should bring specifics this back
> to the case-at-hand thread): what's happening here is our baby switch is
> growing up into a general purpose mechanism.  And, we should expect it
> to take on responsibilities suited to its new abilities.
> 
> 
> Now, for the backlash.  Whenever we make an argument for
> what-appears-to-be relaxing an existing null-hostility, there is much
> concern about how the nulls will run free and wreak havoc. But, let's
> examine that more closely.
> 
> The concern seems to be that, if if we let the null through the gate,
> we'll just get more NPEs, at worse places.  Well, we can't get more
> NPEs; at most, we can get exactly the same number.  But in reality, we
> will likely get less.  There are three cases.
> 
> 1.  The domain is already null-free.  In this case, it doesn't make a
> difference; no NPEs before, none after.
> 
> 2.  The domain is mostly null-free, but nulls do creep in, we see them
> as bugs, and we are happy to get notified.  This is the case today with
> enums, where a null enum is almost always a bug.  Yes, in cases like
> this, not guarding the gates means that the bug will get further before
> it is detected, or might go undetected.  This isn't fantastic, but this
> also isn't a disaster, because it is rare and is still likely it will
> get detected eventually.
> 
> 3.  The domain is at least partially null tolerant.  Here, we are moving
> an always-throw at the gates to a
> might-throw-in-the-guts-if-you-forget.  But also, there are plenty of
> things you can do with a null binding that don't NPE, such as pass it to
> a method that deals sensibly with nulls, add it to an ArrayList, print
> it, etc.  This is a huge improvement, from "must treat null in a
> special, out of band way" to "treat null uniformly."  At worst, it is no
> worse, and often better.
> 
> And, when it comes to general purpose domains, #3 is much bigger than
> #2.  So I think we have to optimize for #3.
> 
> 
> Finally, there are those who argue we should "just" have nullable types
> (T? and T!), and then all of this goes away.  I would love to get there,
> but it would be a very long road.  But let's imagine we do get there.
> OMG how terrible it would be when constructs like lambdas, switches, or
> patterns willfully try to save us from the nulls, thus doing the job
> (badly) of the type system!  We'd have explicitly nullable types for
> which some constructs NPE anyway. Or, we'd have to redefine the
> semantics of everything in complex ways based on whether the underlying
> input types are nullable or not.  We would feel pretty stupid for having
> created new corners to paint ourselves into.
> 
> Our fears of untamed nulls wantonly running through the streets are
> overblown.  Our attempts to contain the nulls through ad-hoc
> gate-guarding have all been failures.  Let the nulls flow.

I think we mostly agree, but let the nulls flow is an ambiguous sentence.
If the question is should a switch never allow null, obviously the answer is no, but at the same time, i want to keep the analogy that a switch is just a compact form of a cascade of if ... value/instanceof/else (nested when there are nested destructuring pattern).

So "case null", the recent mail of Tagir, shake my conviction that "case null" was the right pattern, i think we should allow "case Foo, null" too.
For "case var x", i fully agree with you that it should accept null
For "default", i think we have not talk enough about "default", technically we don't need one in a switch on types, "case var x" is enough, so my first idea was to disallow "default" apart inside where it is already allowed for backward compatibility. This trick avoids to have to define a semantics for "default". Note that if we keep "default", it has to accept null because fundamentally, "default" is like "else".

For a destructuring pattern on Foo, if there is no "case Foo(null)", "case Foo(var x)" (or case var x at the top-level/or default), i don't see why this pattern has to accept "null" when destructutring because the switch will not know what to do with that null. Raising a NPE the earliest seems the right semantics for me.

For a statement switch, if we want to be 100% compatible, a statement switch behave like there is always a "default", so it should be always accept null at the top level.
I tried the usual jedi mind trick to convince you that a statement switch doesn't have to behave like a legacy switch but my force power doesn't seem to work through internet.
In that case, i suppose we can choose among the solutions proposed by Guy to get a statement switch which has no implicit "default".

Rémi
  





More information about the amber-spec-experts mailing list