jspecify: Position on types (in APIs) that observably include null only when "something has gone wrong"

A type has “lifecycle” if a constructed instance may be observed in either a pre-initialized or post-invalidation state, in addition to its regular “alive” state. Ideally, methods called in inappropriate states would always throw IllegalStateException (we can’t rely on this).

Here is just a simplistic example.

public class SoAlive {
  private State state = State.PRE;
  private Foo foo = null;

  public void init() {
    checkState(state == State.PRE);
    foo = somethingNonNull();
    state = State.ALIVE;
  }

  /** Throws if you haven't called init() or have called invalidate(). */
  public /* @Nullable or not? */ Foo regularMethod() {
    checkState(state == State.ALIVE);
    return foo;
  }

  public void invalidate() {
    checkState(state == State.ALIVE);

    @SuppressWarnings("nullness") // safe because all methods should throw ISE now
    foo = null; // it shouldn't nec. have to do this, but say it does
    state = State.POST;
  }

  private enum State { PRE, ALIVE, POST }
}

I believe it’s important to null-annotated these members with the “alive” state in mind. So, I would not put @Nullable on the regularMethod return type.

Why: Since most APIs are invoke-mostly (not override-mostly), we prioritize the needs of the consumer over the implementor. That consumer’s need is for accurate information about valid instances. If they try to use an invalid instance, “all bets are off” (of course we could explore solutions for helping them not do that, but that’s separate). So there’s no compelling reason to give up accurate/useful information about the alive state.

But this has a few advantages for the implementor as well:

  • It’s good that we’d get a warning in init if somethingNonNull is nullable
  • It’s good that we have to suppress a warning in invalidate
  • I think it’s probably even good that a tool might give a warning on the constructor saying that not all non-nullable fields were set; it’s just slightly unfortunate that that would have to be suppressed once-and-for-all on the whole constructor.

Problems caused or remaining:

  • If init forgets to set foo, can we even catch that bug?
  • If someone is doing runtime-check injection based on this, it could break. This is a slightly more general problem though. If it becomes important, we’ll need to have ways to indicate that a certain check should be skipped.

Unclear:

  • It seems debatable whether the lack of a warning where foo is used as non-nullable in regularMethod is a good thing or not. I think the real concern would be making sure state is always checked, which is nullness-agnostic, and which we could look into to the extent it starts to feel important.

(split from #39)

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 21

Most upvoted comments

With #283 written up now, would this be an acceptable resolution for this issue?

“Your goal would typically be to annotate for the states that consumers of your API are actually expected to encounter; however you should be aware of the risks of null pollution (link).”

I was thinking about this a bit more as well and I think I agree: while uninitialized fields are certainly observable, one might still argue they shouldn’t be read while they’re uninitialized (which I believe is Scala 3’s approach as well). Or put another way, reading them before initializing seems like it would usually be unintentional aka a bug. So the idea that @NonNull fields should never be null when read would still hold.

Granted initialization issues hopefully typically aren’t a concern from an API perspective. But they can be a concern for subclasses, so this could be relevant for APIs that provide non-final classes for the purpose of subtyping. java.io’s PipedInputStream/PipedOutputStream may be an interesting example for all this: they have a lifecycle, and they also define protected fields.

Regarding explicitly written nullness assertions on fields, I suppose one might argue these fields can and should be annotated @Nullable, not least since checkers would otherwise consider these checks pointless.