Classes and interfaces are typically open-ended, both in the sense of having an open-ended number of instances (e.g. clients can create arbitrarily many instances of class ArrayList
) and in the sense of having an open-ended number of direct subtypes (e.g. clients can define arbitrarily many classes that implement interface List
).
However, sometimes it makes more sense for a type to be closed, either in the sense of not allowing clients to create new instances, or in the sense of not allowing clients to define new direct subtypes, or both.
For example, consider a class Score
whose instances represent the possible scores that a player can have during a game in a tennis match: 0 (love), 15, 30, and 40. We can define such a class as follows:
public class Score {
public static final Score LOVE = new Score(0, "LOVE", 0);
public static final Score FIFTEEN = new Score(1, "FIFTEEN", 15);
public static final Score THIRTY = new Score(2, "THIRTY", 30);
public static final Score FORTY = new Score(3, "FORTY", 40) {
@Override
public Score next() { throw new UnsupportedOperationException(); }
};
private static final Score[] values = {LOVE, FIFTEEN, THIRTY, FORTY};
public static Score[] values() { return values.clone(); }
private final int ordinal;
private final String name;
private final int value;
public int ordinal() { return ordinal; }
public String name() { return name; }
public int value() { return value; }
public Score next() { return values[ordinal + 1]; }
private Score(int ordinal, String name, int value) {
this.ordinal = ordinal;
this.name = name;
this.value = value;
}
}
We declare the constructor as private
so that clients cannot create new instances.
In fact, Java supports a more concise syntax for declaring such classes with an enumerated set of instances:
public enum Score {
LOVE(0),
FIFTEEN(15),
THIRTY(30),
FORTY(40) {
@Override
public Score next() { throw new UnsupportedOperationException(); }
};
private final int value;
public int value() { return value; }
public Score next() { return values()[ordinal() + 1]; }
private Score(int value) { this.value = value; }
}
Java has convenient syntax for performing case analysis on an enum class instance, in the form of switch statements and switch expressions:
public String getScoreInFrench(Score score) {
switch (score) {
case LOVE -> { return "zéro"; }
case FIFTEEN -> { return "quinze"; }
case THIRTY -> { return "trente"; }
default -> { return "quarante"; }
}
}
public String getScoreInFrench(Score score) {
return switch (score) {
case LOVE -> "zéro";
case FIFTEEN -> "quinze";
case THIRTY -> "trente";
case FORTY -> "quarante";
};
}
Consider an interface GameState whose instances are intended to represent the various states that a game of tennis can be in:
public interface GameState {
public record Regular(Score serverScore, Score receiverScore) implements GameState {
public Regular {
Objects.requireNonNull(serverScore);
Objects.requireNonNull(receiverScore);
}
}
public record Advantage(boolean server) implements GameState {}
public record Won(boolean server) implements GameState {}
}
We can prevent clients from defining additional classes that implement interface GameState by declaring it as sealed:
public sealed interface GameState
permits GameState.Regular, GameState.Advantage, GameState.Won
{ /* ... */ }
In this example, we can in fact just leave out the permits
clause. This means only direct subtypes declared in the same file are allowed:
public sealed interface GameState { /* ... */ }
We can use switch statements or switch expressions to perform case analysis on an instance of a sealed type:
public String toString(GameState state) {
return switch (state) {
case GameState.Regular(var serverScore, var receiverScore) ->
serverScore.value() + "-" + receiverScore.value();
case GameState.Advantage(var server) ->
"advantage " + (server ? "server" : "receiver");
case GameState.Won(var server) ->
"won by the " + (server ? "server" : "receiver");
};
}