preface: My answer is more theoretical, and the practices it describes aren't really practical in Java. They're simply not as well supported, and you would be "going against the grain", conventionally speaking. Regardless, I think it's a neat pattern to know about, and I thought I would share.
Java's classes are product types. When a class C
contains members of types T1
, T2
, ..., Tn
, then the valid values for objects of class C
are the Cartesian product of the values of T1
, T2
, ..., Tn
. For example, if class C
contains a bool
(which has 2
values) and byte
(which has 256
values), then there are 512
possible values of C
objects:
(false, -128)
(false, -127)
- ...
(false, 0)
...
(false, 127)
(true, -128)
(true, -127)
- ...
(true, 0)
...
(true, 127)
In your example, the theoretical possible values of ExclusiveField
is equal to numberOfValuesOf(BigInteger.class) * numberOfValuesOf(String) * numberOfValuesOf(LocalDateTime)
(notice the multiplication, that's why it's called a product type), but that's not really what you want. You're looking for ways to eliminate a huge set of these combinations so that the only values are when one field is non-null, and the others are null. There are numberOfValuesOf(BigInteger.class) + numberOfValuesOf(String) + numberOfValuesOf(LocalDateTime)
. Notice the addition, this indicates that what you're looking for is a "sum type".
Formally speaking, what you're looking for here is a tagged union (also called a variant, variant record, choice type, discriminated union, disjoint union, or sum type). A tagged union is a type whose values are a choice between one value of the members. In the previous example, if C
was a sum type, there would be only 258 possible values: -128
, -127
, ..., 0
, 127
, true
, false
.
I recommend you check out unions in C, to build an understanding of how this works. The issue with C is that its unions had no way of "remembering" which "case" was active at any given point, which mostly defeats the whole purpose of a "sum type". To remedy this, you would add a "tag", which was an enum, whose value tells you what the state of the union is. "Union" stores the payload, and the "tag" tells you to the type of the payload, hence "tagged union".
The problem is, Java doesn't really have such a feature built in. Luckily, we can harness class hierarchies (or interfaces) to implement this. You essentially have to roll your own every time you need it, which is a pain because it takes a lot of boilerplate, but it's conceptually simple:
- For
n
different cases, you make n
different private classes, each storing the members pertinent to that case
- You unify these private classes under a common base class (typically abstract) or interface
- You wrap these classes in a forwarding class that exposes a public API all while hiding the private internals (to ensure that no one else can implement your interface).
Your interface could have n
methods, each something like getXYZValue()
. These methods could be made as default methods, where the default implementation returns null
(for Object
values, but doesn't work for primitives, Optional.empty()
(for Optional<T>
values), or throw
an exception (gross, but there's no better way for primitive values like int
). I don't like this approach, because the interface is rather disingenuous. Conforming types don't really conform to the interface, only ¹/n th of it.
Instead, you can use a pattern matching uhhh, pattern. You make a method (e.g. match
) that takes n
different Function
parameters, whose types correspond to the types of cases of the discriminated union. To use a value of the discriminated union, you match it and provide n
lambda expressions, each of which acts like the cases in a switch
statement. When invoked, the dynamic dispatch system calls the match
implementation associated with the particular storage
object, which calls the correct one of the n
functions and passes its value.
Here's an example:
import java.util.Optional;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
import java.util.function.Consumer;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import java.math.BigInteger;
class Untitled {
public static void main(String[] args) {
List<ExclusiveField> exclusiveFields = Arrays.asList(
ExclusiveField.withBigIntegerValue(BigInteger.ONE),
ExclusiveField.withDateValue(LocalDateTime.now()),
ExclusiveField.withStringValue("ABC")
);
for (ExclusiveField field : exclusiveFields) {
field.consume(
i -> System.out.println("Value was a BigInteger: " + i),
d -> System.out.println("Value was a LocalDateTime: " + d),
s -> System.out.println("Value was a String: " + s)
);
}
}
}
class ExclusiveField {
private ExclusiveFieldStorage storage;
private ExclusiveField(ExclusiveFieldStorage storage) { this.storage = storage; }
public static ExclusiveField withBigIntegerValue(BigInteger i) { return new ExclusiveField(new BigIntegerStorage(i)); }
public static ExclusiveField withDateValue(LocalDateTime d) { return new ExclusiveField(new DateStorage(d)); }
public static ExclusiveField withStringValue(String s) { return new ExclusiveField(new StringStorage(s)); }
private <T> Function<T, Void> consumerToVoidReturningFunction(Consumer<T> consumer) {
return arg -> {
consumer.accept(arg);
return null;
};
}
// This just consumes the value, without returning any results (such as for printing)
public void consume(
Consumer<BigInteger> bigIntegerMatcher,
Consumer<LocalDateTime> dateMatcher,
Consumer<String> stringMatcher
) {
this.storage.match(
consumerToVoidReturningFunction(bigIntegerMatcher),
consumerToVoidReturningFunction(dateMatcher),
consumerToVoidReturningFunction(stringMatcher)
);
}
// Transform 'this' according to one of the lambdas, resuling in an 'R'.
public <R> R map(
Function<BigInteger, R> bigIntegerMatcher,
Function<LocalDateTime, R> dateMatcher,
Function<String, R> stringMatcher
) {
return this.storage.match(bigIntegerMatcher, dateMatcher, stringMatcher);
}
private interface ExclusiveFieldStorage {
public <R> R match(
Function<BigInteger, R> bigIntegerMatcher,
Function<LocalDateTime, R> dateMatcher,
Function<String, R> stringMatcher
);
}
private static class BigIntegerStorage implements ExclusiveFieldStorage {
private BigInteger bigIntegerValue;
BigIntegerStorage(BigInteger bigIntegerValue) { this.bigIntegerValue = bigIntegerValue; }
public <R> R match(
Function<BigInteger, R> bigIntegerMatcher,
Function<LocalDateTime, R> dateMatcher,
Function<String, R> stringMatcher
) {
return bigIntegerMatcher.apply(this.bigIntegerValue);
}
}
private static class DateStorage implements ExclusiveFieldStorage {
private LocalDateTime dateValue;
DateStorage(LocalDateTime dateValue) { this.dateValue = dateValue; }
public <R> R match(
Function<BigInteger, R> bigIntegerMatcher,
Function<LocalDateTime, R> dateMatcher,
Function<String, R> stringMatcher
) {
return dateMatcher.apply(this.dateValue);
}
}
private static class StringStorage implements ExclusiveFieldStorage {
private String stringValue;
StringStorage(String stringValue) { this.stringValue = stringValue; }
public <R> R match(
Function<BigInteger, R> bigIntegerMatcher,
Function<LocalDateTime, R> dateMatcher,
Function<String, R> stringMatcher
) {
return stringMatcher.apply(this.stringValue);
}
}
}