Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Java Records and Null Object Pattern?

Is there any way to do Null Objects with Java Records? With classes I'd do it like that:

public class Id {

  public static final Id NULL_ID = new Id();

  private String id;

  public Id(String id) {
    this.id = Objects.requireNonNull(id);
  }

  private Id() {}
}

But that does not work, because every constructor needs go through the canonical (Id(String id) one and I can't just call super() to go around the invariants.

public record Id(String id) {
  public static final Id NULL_ID = null; // how?

  public Id {
    Objects.requireNonNull(id);
    // ...
  }
}

Right now I work around this with

public Id {
  if (NULL_OBJECT != null)
    Objects.requireNonNull(id);
}

but that feels wrong and open to concurrency problems.

I haven't found a lot of discussion about the design ideas behind records and this may have been already discussed. If it's like that to keep it simple that's understandable, but it feels awkward and I've hit that problem multiple times already in small samples.

like image 878
atamanroman Avatar asked Jul 08 '20 16:07

atamanroman


2 Answers

I would strongly suggest you stop using this pattern. It's got all sorts of problems:

Basic errors in the code

Your NULL_ID field isn't final which it clearly should be.

Null object vs. Empty object

There are 2 concepts that seem similar or even the same but they aren't.

There's the unknown / not found / not applicable concept. For example:

Map<String, Id> studentIdToName = ...;
String name = studentIdToName.get("foo");

What should name be if "foo" is not in the map?

Not so fast - before you answer: Well, maybe "" - that would lead to all sorts of problems. If you wrote code that mistakenly thinks the id used is definitely in this map, then it's a fait accompli: This code is bugged. Period. All we can do now is ensure that this bug is dealt with as 'nicely' as possible.

And saying that name is null here, is strictly superior: The bug will now be explicit, with a stack trace pointing at the offending code. Absence of stack trace is not proof of bug free code - not at all. If this code returns the empty string and then sends an email to a blank mail address with a body that contains an empty string where the name should be, that's much worse than the code throwing an NPE.

For such a value (not found / unknown / not applicable), nothing in java beats null as value.

However, what does occur quite often when working with APIs that are documented to perhaps return null (that is, APIs that may return 'not applicable', 'no value', or 'not found'), is that the caller wants to treat this the same as a known convenient object.

For example, if I always uppercase and trim the student name, and some ids are already mapped to 'not enrolled anymore' and this shows up as having been mapped to the empty string, then it can be really convenient for the caller to desire for this specific use case that not-found ought to be treated as empty string. Fortunately, the Map API caters to this:

String name = map.getOrDefault(key, "").toUpperCase().trim();
if (name.isEmpty()) return;
// do stuff here, knowing all is well.

The crucial tool that you, API designer, should provide, is an empty object.

Empty objects should be convenient. Yours is not.

So, now that we've established that a 'null object' is not what you want, but an 'empty object' is great to have, note that they should be convenient. The caller already decided on some specific behaviour they want; they explicitly opted into this. They don't want to then STILL have to deal with unique values that require special treatment, and having an Id instance whose id field is null fails the convenience test.

What you'd want is presumably an Id that is fast, immutable, easily accessible, and has an empty string for id. not null. Be like "", or like List.of(). "".length() works, and returns 0. someListIHave.retainAll(List.of()) works, and clears the list. That's the convenience at work. It is dangerous convenience (in that, if you weren't expecting a dummy object with certain well known behaviours, NOT erroring on the spot can hide bugs), but that's why the caller has to explicitly opt into it, e.g. by using getOrDefault(k, THE_DUMMY).

So, what should you write here?

Simple:

private static final Id EMPTY = new Id("");

It is possible you need for the EMPTY value to have certain specific behaviours. For example, sometimes you want the EMPTY object to also have the property that it is unique; that no other instance of Id can be considered equal to it.

You can solve that problem in two ways:

  1. Hidden boolean.
  2. by using EMPTY as an explicit identity.

I assume 'hidden boolean' is obvious enough. a private boolean field that a private constructor can initialize to true, and all publically accessible constructors set to false.

Using EMPTY as identity is a bit more tricky. It looks, for example, like this:

@Override public boolean equals(Object other) {
    if (other == null || !other.getClass() == Id.class) return false;
    if (other == this) return true;
    if (other == EMPTY || this == EMPTY) return false;
    return ((Id) other).id.equals(this.id);
}

Here, EMPTY.equals(new Id("")) is in fact false, but EMPTY.equals(EMPTY) is true.

If that's how you want it to work (questionable, but there are use cases where it makes sense to decree that the empty object is unique), have at it.

like image 97
rzwitserloot Avatar answered Nov 19 '22 11:11

rzwitserloot


No, what you want is not possible with the current definition of records in Java 14. Every record type has a single canonical constructor, either defined implicitly or explicitly. Every non-canonical constructor has to start with an invocation of another constructor of this record type. This basically means, that a call to any other constructor definitely results in a call to the canonical constructor. [8.10.4 Record Constructor Declarations in Java 14]

If this canonical constructor does the argument validation (which it should, because it's public), your options are limited. Either you follow one of the suggestions/workarounds already mentioned or you only allow your users to access the API through an interface. If you choose this last approach, you have to remove the argument validation from the record type and put it in the interface, like so:

public interface Id {
    Id NULL_ID = new IdImpl(null);

    String id();

    static Id newIdFrom(String id) {
        Objects.requireNonNull(id);
        return new IdImpl(id);
    }
}

record IdImpl(String id) implements Id {}

I don't know your use case, so that might not be an option for you. But again, what you want is not possible right now.

Regarding Java 15, I could only find the JavaDoc for Records in Java 15, which seems to not have changed. I couldn't find the actual specification, the link to it in the JavaDoc leads to a 404, so maybe they have already relaxed the rules, because some people complained about them.

like image 34
Alex R Avatar answered Nov 19 '22 12:11

Alex R