Het artikel bespreekt de introductie van sealed classes in Java 17, waarmee ontwikkelaars expliciet kunnen definiëren welke sub-types van een interface of basisklasse zijn toegestaan.
Het belangrijkste voordeel hiervan is exhaustiveness checking (uitputtendheidscontrole). Hierdoor kan de compiler controleren of alle mogelijke gevallen in een switch-expressie zijn behandeld, waardoor een default-clausule overbodig wordt. Indien een case wordt vergeten, geeft de compiler direct een foutmelding.
De auteur waarschuwt echter voor twee belangrijke risico's:
- Breaking Changes: Het toevoegen van een nieuw subtype aan een sealed class is een brekende wijziging die, volgens SemVer, een grote versie-update zou vereisen.
- Runtime-fouten: Indien een sealed type wordt uitgebreid maar de consument van de code niet opnieuw wordt gecompileerd, kan er een
MatchException optreden tijdens runtime.
Conclusie: Sealed types zijn zeer waardevol voor interne implementaties binnen een component waar alles samen wordt gecompileerd, maar ze zijn riskant wanneer ze worden blootgesteld in een publieke API.
Java sealed classes en uitputtende pattern matching
Java 17 introduceerde sealed classes, waarmee je expliciet een lijst kunt opstellen van de toegestane sub-types van een interface of basisklasse. Hier is een eenvoudig voorbeeld met een sealed interface en records (inner classes worden impliciet toegevoegd aan de toegestane sub-types als er geen expliciete lijst is gegeven):
public sealed interface SealedType {
record TypeA() implements SealedType {}
record TypeB() implements SealedType {}
static SealedType of(String type) {
return switch (type) {
case "A" -> new TypeA();
case "B" -> new TypeB();
default -> throw new IllegalArgumentException();
};
}
}
Voor wie bekend is met functionele programmeertalen met algebraïsche datatypes, kan dit worden gezien als vergelijkbaar met een datatype-declaratie in Haskell of ML:
data SealedType = TypeA | TypeB
We kunnen dit vervolgens gebruiken in een eenvoudige Main-klasse:
void main(String[] args) {
var val = SealedType.of(args[0]);
System.out.println(switch (val) {
case SealedType.TypeA() -> "A";
case SealedType.TypeB() -> "B";
});
}
Hoewel dit misschien niet spectaculair lijkt, is het belangrijk om op te merken dat we geen default-clausule hoefden toe te voegen aan de switch-expressie in onze main-methode. Dit komt doordat sealed classes (en enums) exhaustiveness checking (uitputtendheidscontrole) mogelijk maken: de compiler weet precies wat de mogelijke gevallen zijn en kan dus controleren of je ze allemaal hebt gedekt. Als dat het geval is, is een default-clausule niet nodig. Vergeet je er één (en heb je geen default-clausule), dan krijg je een compile-time-fout.
Dit is ideaal wanneer je wilt garanderen dat alle implementaties van een bepaald type alle gevallen behandelen, maar het introduceert ook een nieuw type breaking change: het toevoegen van een nieuw sub-type aan een sealed class of interface kan consumenten van die code breken. In ons voorbeeld zal het toevoegen van een nieuwe TypeC ervoor zorgen dat de main-methode niet meer compileert vanwege de ontbrekende case. Als je dus een sealed type exporteert in je API, is het toevoegen van een nieuw subtype een breaking change die een grote versie-update zou vereisen (indien je SemVer volgt).
Compile-time- of runtime-fout?
Hoewel Java een compile-time-fout geeft voor een niet-uitputtende switch wanneer je de consument (in dit geval Main) compileert, kan dit niet gebeuren als de consument niet opnieuw wordt gecompileerd wanneer het sealed type verandert. Stel dat we onze SealedType uitbreiden met een extra case:
public sealed interface SealedType {
record TypeA() implements SealedType {}
record TypeB() implements SealedType {}
record TypeC() implements SealedType {}
static SealedType of(String type) {
return switch (type) {
case "A" -> new TypeA();
case "B" -> new TypeB();
case "C" -> new TypeC();
default -> throw new IllegalArgumentException();
};
}
}
Als we alleen SealedType.java opnieuw compileren en Main niet, dan eindigen we met een runtime-exception wanneer de nieuwe case wordt aangeroepen:
% java Main C
Exception in thread "main" java.lang.MatchException
at Main.main(Main.java:3)
Hier zien we dat de nieuwe MatchException wordt gegooid. De Javadoc merkt dit potentiële probleem met afzonderlijke compilatie op, evenals enkele randgevallen met null in patterns. Zelfs als je hoopte dat sealed classes statisch zouden garanderen dat alle consumenten worden bijgewerkt wanneer een nieuwe case wordt toegevoegd, is dit dus niet het geval, tenzij alles opnieuw wordt gecompileerd.
Conclusies
Mijn conclusie is dat sealed types waarschijnlijk het meest nuttig zijn binnen de implementatie van een component, en minder nuttig wanneer ze worden blootgesteld in de publieke API die een component aan andere componenten aanbiedt (bijv. een bibliotheek). Voor intern gebruik, waarbij je doorgaans alles samen opnieuw compileert, profiteer je van de voordelen van exhaustiveness checking en hogere compile-time veiligheidsgaranties. Maar wanneer ze over modulegrenzen heen worden gebruikt, introduceer je mogelijk nieuwe manieren om code te breken, die vaak pas tijdens runtime detecteerbaar zijn.
(Ik ontdekte deze subtiliteiten tijdens het reviewen van de preview-ondersteuning voor PEM-gecodeerde cryptografische objecten. Daar werd precies deze fout gemaakt door een sealed interface in een publieke API te verwerken en cliënten aan te raden om pattern matching tegen dat type te gebruiken. Ik voorspel een zeer grote kans op breuk als ze ooit een nieuwe case willen toevoegen).
Java sealed classes en uitputtende pattern matching
Java 17 introduceerde sealed classes, waarmee je expliciet een lijst kunt opstellen van de toegestane sub-types van een interface of basisklasse. Hier is een eenvoudig voorbeeld met een sealed interface en records (inner classes worden impliciet toegevoegd aan de toegestane sub-types als er geen expliciete lijst is gegeven):
public sealed interface SealedType {
record TypeA() implements SealedType {}
record TypeB() implements SealedType {}
static SealedType of(String type) {
return switch (type) {
case "A" -> new TypeA();
case "B" -> new TypeB();
default -> throw new IllegalArgumentException();
};
}
}
Voor wie bekend is met functionele programmeertalen met algebraïsche datatypes, kan dit worden gezien als vergelijkbaar met een datatype-declaratie in Haskell of ML:
data SealedType = TypeA | TypeB
We kunnen dit vervolgens gebruiken in een eenvoudige Main-klasse:
void main(String[] args) {
var val = SealedType.of(args[0]);
System.out.println(switch (val) {
case SealedType.TypeA() -> "A";
case SealedType.TypeB() -> "B";
});
}
Hoewel dit misschien niet spectaculair lijkt, is het belangrijk om op te merken dat we geen default-clausule hoefden toe te voegen aan de switch-expressie in onze main-methode. Dit komt doordat sealed classes (en enums) exhaustiveness checking (uitputtendheidscontrole) mogelijk maken: de compiler weet precies wat de mogelijke gevallen zijn en kan dus controleren of je ze allemaal hebt gedekt. Als dat het geval is, is een default-clausule niet nodig. Vergeet je er één (en heb je geen default-clausule), dan krijg je een compile-time-fout.
Dit is ideaal wanneer je wilt garanderen dat alle implementaties van een bepaald type alle gevallen behandelen, maar het introduceert ook een nieuw type breaking change: het toevoegen van een nieuw sub-type aan een sealed class of interface kan consumenten van die code breken. In ons voorbeeld zal het toevoegen van een nieuwe TypeC ervoor zorgen dat de main-methode niet meer compileert vanwege de ontbrekende case. Als je dus een sealed type exporteert in je API, is het toevoegen van een nieuw subtype een breaking change die een grote versie-update zou vereisen (indien je SemVer volgt).
Compile-time- of runtime-fout?
Hoewel Java een compile-time-fout geeft voor een niet-uitputtende switch wanneer je de consument (in dit geval Main) compileert, kan dit niet gebeuren als de consument niet opnieuw wordt gecompileerd wanneer het sealed type verandert. Stel dat we onze SealedType uitbreiden met een extra case:
public sealed interface SealedType {
record TypeA() implements SealedType {}
record TypeB() implements SealedType {}
record TypeC() implements SealedType {}
static SealedType of(String type) {
return switch (type) {
case "A" -> new TypeA();
case "B" -> new TypeB();
case "C" -> new TypeC();
default -> throw new IllegalArgumentException();
};
}
}
Als we alleen SealedType.java opnieuw compileren en Main niet, dan eindigen we met een runtime-exception wanneer de nieuwe case wordt aangeroepen:
% java Main C
Exception in thread "main" java.lang.MatchException
at Main.main(Main.java:3)
Hier zien we dat de nieuwe MatchException wordt gegooid. De Javadoc merkt dit potentiële probleem met afzonderlijke compilatie op, evenals enkele randgevallen met null in patterns. Zelfs als je hoopte dat sealed classes statisch zouden garanderen dat alle consumenten worden bijgewerkt wanneer een nieuwe case wordt toegevoegd, is dit dus niet het geval, tenzij alles opnieuw wordt gecompileerd.
Conclusies
Mijn conclusie is dat sealed types waarschijnlijk het meest nuttig zijn binnen de implementatie van een component, en minder nuttig wanneer ze worden blootgesteld in de publieke API die een component aan andere componenten aanbiedt (bijv. een bibliotheek). Voor intern gebruik, waarbij je doorgaans alles samen opnieuw compileert, profiteer je van de voordelen van exhaustiveness checking en hogere compile-time veiligheidsgaranties. Maar wanneer ze over modulegrenzen heen worden gebruikt, introduceer je mogelijk nieuwe manieren om code te breken, die vaak pas tijdens runtime detecteerbaar zijn.
(Ik ontdekte deze subtiliteiten tijdens het reviewen van de preview-ondersteuning voor PEM-gecodeerde cryptografische objecten. Daar werd precies deze fout gemaakt door een sealed interface in een publieke API te verwerken en cliënten aan te raden om pattern matching tegen dat type te gebruiken. Ik voorspel een zeer grote kans op breuk als ze ooit een nieuwe case willen toevoegen).