Java Records and Null Object Pattern?
Asked Answered
B

2

17

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.

Bremerhaven answered 8/7, 2020 at 16:23 Comment(5)
(One) purpose of records is to not have null as a valid value, so this question doesn't really make sense.Partiality
Is that so? That's the kind of argumentation I'm looking for, but openjdk.java.net/jeps/359 does not mention that at all. I have a lot of Value Objects in different codebases where Records could be useful, but their limitations make it awkward.Bremerhaven
Perhaps you could give an example where a "null" record seems necessary?Partiality
Now that you declared NULL_ID as static final, there is no concurrency problem. The condition NULL_OBJECT != null can only evaluate to false within the class initializer which is safe. But that’s not the “Null Object” design pattern. The Null Object design pattern is supposed to prevent client code from encountering null reference related problems. But your record’s id() method still may return null, just the same way a record allowing null would do.Hydrocele
Sidenote: With Eclipse's ejc, the first code block was valid - until someone fixed my bugShipmaster
D
3

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.

Deragon answered 11/7, 2020 at 11:4 Comment(1)
Well, why the down vote? If my answer is wrong, I'll delete it.Deragon
D
5

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.

Diapedesis answered 8/7, 2020 at 17:10 Comment(2)
I appreciate your feedback (and I fixed the missing final). Yet I really didn't want to discuss the pattern itself. It's the technical limitation that's interesting (which might affect how useful records are in general). I might change the example to somewhat more sound, it was just the most concise thing I could imagine.Bremerhaven
@atamanroman, I encourage changing the example. Null objects implement default behavior. Records typically have no behavior. The two seem unlikely to overlap.Heisler
D
3

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.

Deragon answered 11/7, 2020 at 11:4 Comment(1)
Well, why the down vote? If my answer is wrong, I'll delete it.Deragon

© 2022 - 2024 — McMap. All rights reserved.