Make your own Optionals
source link: https://mccue.dev/pages/3-28-23-custom-optional
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Make your own Optionals
Make your own Optionals
This is java.util.Optional
.
I took out all the comments and did a little reformatting, but this is the entire class. Just around 150 lines managing one nullable field.
Take a minute to read or skim it before moving on.
public final class Optional<T> {
private static final Optional<?> EMPTY =
new Optional<>(null);
private final T value;
public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
private Optional(T value) {
this.value = value;
}
public static <T> Optional<T> of(T value) {
return new Optional<>(Objects.requireNonNull(value));
}
@SuppressWarnings("unchecked")
public static <T> Optional<T> ofNullable(T value) {
return value == null ? (Optional<T>) EMPTY
: new Optional<>(value);
}
public T get() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}
public boolean isPresent() {
return value != null;
}
public boolean isEmpty() {
return value == null;
}
public void ifPresent(Consumer<? super T> action) {
if (value != null) {
action.accept(value);
}
}
public void ifPresentOrElse(
Consumer<? super T> action,
Runnable emptyAction
) {
if (value != null) {
action.accept(value);
} else {
emptyAction.run();
}
}
public Optional<T> filter(Predicate<? super T> predicate) {
Objects.requireNonNull(predicate);
if (!isPresent()) {
return this;
} else {
return predicate.test(value) ? this : empty();
}
}
public <U> Optional<U> map(
Function<? super T, ? extends U> mapper
) {
Objects.requireNonNull(mapper);
if (!isPresent()) {
return empty();
} else {
return Optional.ofNullable(mapper.apply(value));
}
}
public <U> Optional<U> flatMap(
Function<? super T, ? extends Optional<? extends U>> mapper
) {
Objects.requireNonNull(mapper);
if (!isPresent()) {
return empty();
} else {
@SuppressWarnings("unchecked")
Optional<U> r = (Optional<U>) mapper.apply(value);
return Objects.requireNonNull(r);
}
}
public Optional<T> or(
Supplier<? extends Optional<? extends T>> supplier
) {
Objects.requireNonNull(supplier);
if (isPresent()) {
return this;
} else {
@SuppressWarnings("unchecked")
Optional<T> r = (Optional<T>) supplier.get();
return Objects.requireNonNull(r);
}
}
public Stream<T> stream() {
if (!isPresent()) {
return Stream.empty();
} else {
return Stream.of(value);
}
}
public T orElse(T other) {
return value != null ? value : other;
}
public T orElseGet(Supplier<? extends T> supplier) {
return value != null ? value : supplier.get();
}
public T orElseThrow() {
if (value == null) {
throw new NoSuchElementException("No value present");
}
return value;
}
public <X extends Throwable> T orElseThrow(
Supplier<? extends X> exceptionSupplier
) throws X {
if (value != null) {
return value;
} else {
throw exceptionSupplier.get();
}
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
return obj instanceof Optional<?> other
&& Objects.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
@Override
public String toString() {
return value != null
? ("Optional[" + value + "]")
: "Optional.empty";
}
}
Why does Optional
exist
java.util.Optional
was introduced in Java 8 alongside the Stream
API. Its raison d'etre is to make coders explicitly consider what to do when using methods like findFirst
on a potentially empty Stream
.
// Explicitly throws
int valueOne = list
.stream()
.map(x -> x + 1)
.filter(x -> x % 2 == 0)
.findFirst()
.orElseThrow()
// Explicitly uses a default value
int valueTwo = list
.stream()
.map(x -> x + 1)
.filter(x -> x % 2 == 0)
.findFirst()
.orElse(0)
The deficiency it targets is in the interaction between null
and "method chaining style". When there are so many method calls stacked up, it is hard for people to remember to handle cases like null
return values.
So with streams poised to encourage method chaining, Optional
was needed to make that API not lead to hidden bugs.
What's wrong with Optional
?
Nothing really.
The core tension that leads to so much discourse is that there is no way to represent null
in Java's type system. Whether from lived experience or religious fervor, folks tend to be afraid of an unaccounted for null
.
Because Optional
is in the standard library and explicitly represents "absence or presence", it is extremely tempting to just replace every nullable thing with an Optional<T>
.
Doing this can lead to code that sucks, especially if you try to avoid null
for local variables.
// Some might try to use isPresent()/get() to avoid null
Optional<String> nameOpt = f();
Optional<Integer> ageOpt = g();
if (nameOpt.isPresent() && ageOpt.isPresent()) {
var name = nameOpt.get();
var age = nameOpt.get();
System.out.println(
name + " is " + age + " years old"
);
}
// Others might try to map/flatMap.
Optional<String> nameOpt = f();
Optional<Integer> ageOpt = g();
nameOpt
.flatMap(name ->
ageOpt.map(age -> {
System.out.println(
name + " is " + age + " years old"
);
}))
// But its questionable what's gained over null.
String name = f().orElse(null);
Integer age = g().orElse(null);
if (name != null && age != null) {
System.out.println(
name + " is " + age + " years old"
);
}
But this is honestly fine.
Yes, the Optional
will use up more memory and perform a bit worse than the equivalent code with null
. Yes, code written with isPresent
/get
/orElseThrow
or map
/flatMap
can be a bit crusty. Yes, it wasn't intended to be a field or a method parameter. There are a lot of bike sheds to build and "best practices" to get into internet fights over.
But jspecify
is poised to give standard nullability annotations and tooling to augment the type system with them. Project Valhalla is considering giving a way to express null restricted storage. In the fullness of time, the core tension that leads to this "overuse" seems like it will be resolved.
The problem with both Optional
and null
is that they only convey that some data might be absent and not what being absent implies.
The Meaning of Absence
Say you were writing a program which had to record peoples' first and last names for legal reasons. Users can still sign up, but they will need to give that information before continuing on to other parts of the app.
Today you might see Optional
being used to represent that.
import java.util.Optional;
record Person(
int id,
Optional<String> firstName,
Optional<String> lastName
) {}
In the near future, maybe a @Nullable
annotation.
import org.jspecify.annotations.Nullable;
record Person(
int id,
@Nullable String firstName,
@Nullable String lastName
) {}
In both cases - null
and an empty Optional
- an absent value implies that you have not been given that information yet.
You can use this to know when to stop a user and ask them for their name.
import java.util.Optional;
record Person(
int id,
Optional<String> firstName,
Optional<String> lastName
) {
boolean shouldAskForInfo() {
return firstName.isEmpty() || lastName.isEmpty();
}
}
import org.jspecify.annotations.Nullable;
record Person(
int id,
@Nullable String firstName,
@Nullable String lastName
) {
boolean shouldAskForInfo() {
return firstName == null || lastName == null;
}
}
Now, consider Madonna. Madonna does not have a last name. If a null
or empty value in the lastName
field means "not provided", you have no way to directly represent "known to not exist."
// Need to ask Bob for his last name still
var bob = new Person(1, "Bob", null);
// Shouldn't ask Madonna for anything
var madonna = new Person(2, "Madonna", null);
Using an empty string is tempting, but if you do that you will have the same problem that null
currently has. By having a "special" value not expressed in the type system, you are liable to forget to check for that special value.
// Empty string can be a sentinel
var madonna = new Person(2, "Madonna", "");
// But if you forget that it is special
// you might give Madonna a subpar user experience
var welcome = "Hello "
+ person.firstName()
+ " "
+ person.lastName()
+ "!";
// "Hello Madonna !"
// She'll notice. She'll hate you.
The reality of our fictional data model is that we have three distinct cases.
- We have not been given a last name.
- We have been told there is no last name.
- We have been given a last name.
The most convenient tool we have for representing this sort of situation is a sealed interface
.
sealed interface LastName {
record NotGiven() implements LastName {}
record DoesNotExist() implements LastName {}
record Given(String value) implements LastName {}
}
Now when a LastName
has an absent value, we can know whether that is because it doesn't exist or we just haven't been told.
import org.jspecify.annotations.Nullable;
record Person(
int id,
@Nullable String firstName,
LastName lastName
) {
boolean shouldAskForInfo() {
return firstName == null
|| lastName instanceof LastName.NotGiven;
}
}
And we can properly represent Madonna.
// Need to ask Bob for his last name still
var bob = new Person(1, "Bob", new LastName.NotGiven());
// Shouldn't ask Madonna for anything
var madonna = new Person(2, "Madonna", new LastName.DoesNotExist());
// Joe is all set
var joe = new Person(3, "Joe", new LastName.Given("Shmoe"));
Optional
and null
let you represent exactly 2 possibilities, a sealed hierarchy lets you represent 2 or more possibilities. The reason I'm using the Madonna example is that it is a straw-man where you want to represent 3 distinct possibilities.
My bold claim is that even when there are only 2 possibilities you should still consider making your own class instead of using Optional
or null
.
Both @Nullable String firstName
and Optional<String> firstName
do not directly convey what it means if the data is missing. Its just "absent." The fact that it means you haven't been told is context external to your domain model.
It's a similar problem to primitive obsession. Because null
and Optional
are there and fit the "shape" we want we gravitate to them.
What if instead of that we were to make our own "optional" class.
sealed interface FirstName {
record NotGiven() implements FirstName {}
record Given(String value) implements FirstName {}
}
So here FirstName
is identical in spirit to an Optional<String>
, but with the benefit of us being able to give a name to the situation where there is no value. It's not empty or present, we were either given a first name or we weren't.
With pattern matching you will be able switch over these two situations.
switch (person.firstName()) {
case FirstName.NotGiven _ ->
System.out.println("No first name");
case FirstName.Given(String name) ->
System.out.println("First name is " + name);
}
And part of the reason I put all the code for Optional
at the top was to impress upon you how trivial it would be to add any of those helper methods to a class you made yourself.
sealed interface FirstName {
record NotGiven() implements FirstName {
@Override
public String orElse(String defaultValue) {
return defaultValue;
}
}
record Given(String value) implements FirstName {
@Override
public String orElse(String defaultValue) {
return this.value;
}
}
String orElse(String defaultValue);
}
var name = person.name().orElse("?");
That's it. That's the thesis.
If you are spending time modeling your domain objects, consider making your own versions of an Optional
class. You can choose names which more align with your domain, adapt to more varied situations, and the boilerplate for doing so is at a historic low.
I will admit that if you have a huge number of fields with potentially missing data this can be more trouble than its worth. I still think its worth considering.
<- Index
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK