A few years back I posted about how to implement state machines that only permit valid transitions at compile time in Java.
This used interfaces instead of enums, which had a big drawback—you couldn’t guarantee that you know all the states involved. Someone could add another state elsewhere in your codebase by implementing the interface.
Java 15 brings a preview feature of sealed classes. Sealed classes enable us to solve this downside. Now our interface based state machines can not only prevent invalid transitions but also be enumerable like enums.
If you’re using jdk 15 with preview features enabled you can try out the code. This is how it looks to define a state machine with interfaces.
sealed interface TrafficLight extends State<TrafficLight> permits Green, SolidAmber, FlashingAmber, Red {} static final class Green implements TrafficLight, TransitionTo<SolidAmber> {} static final class SolidAmber implements TrafficLight, TransitionTo<Red> {} static final class Red implements TrafficLight, TransitionTo<FlashingAmber> {} static final class FlashingAmber implements TrafficLight, TransitionTo<Green> {} |
The new part is “sealed” and “permits”. Now it becomes a compile failure to define a new implementation of TrafficLight
As well as the existing behaviour where it’s a compile time failure to perform a transition that traffic lights do not allow.
n.b. you can also skip the compile time checked version and still use the type definitions to runtime check the transitions.
Multiple transitions are possible from a state too
static final class Pending implements OrderStatus, BiTransitionTo<CheckingOut, Cancelled> {} |
Thanks to sealed classes we can also now do enum style enumeration and lookups on our interface based state machines.
sealed interface OrderStatus extends State<OrderStatus> permits Pending, CheckingOut, Purchased, Shipped, Cancelled, Failed, Refunded {} @Test public void enumerable() { assertArrayEquals( array(Pending.class, CheckingOut.class, Purchased.class, Shipped.class, Cancelled.class, Failed.class, Refunded.class), State.values(OrderStatus.class) ); assertEquals(0, new Pending().ordinal()); assertEquals(3, new Shipped().ordinal()); assertEquals(Purchased.class, State.valueOf(OrderStatus.class, "Purchased")); assertEquals(Cancelled.class, State.valueOf(OrderStatus.class, "Cancelled")); } |
These are possible because JEP 360 provides a reflection API with which one can enumerate the permitted subclasses of an interface. ( side note the JEP says getPermittedSubclasses() but the implementation seems to use permittedSubclasses() )
We can add use this to add the above convenience methods to our State interface to allow the values(), ordinal(), and valueOf() lookups.
static <T extends State<T>> List<Class> valuesList(Class<T> stateMachineType) { assertSealed(stateMachineType); return Stream.of(stateMachineType.permittedSubclasses()) .map(State::classFromDesc) .collect(toList()); } static <T extends State<T>> Class<T> valueOf(Class<T> stateMachineType, String name) { assertSealed(stateMachineType); return valuesList(stateMachineType) .stream() .filter(c -> Objects.equals(c.getSimpleName(), name)) .findFirst() .orElseThrow(IllegalArgumentException::new); } static <T extends State<T>, U extends T> int ordinal(Class<T> stateMachineType, Class<U> instanceType) { return valuesList(stateMachineType).indexOf(instanceType); } |
There are more details on how the transition checking works and more examples of where this might be useful in the original post. Code is on github.