Blog
Kompiliersicheres Builder-Muster mit Phantom Types in Scala


In diesem kurzen Artikel erstellen wir ein klassisches Builder-Muster, um die Leistungsfähigkeit von Phantomtypen zu demonstrieren. Phantomtypen stellen dem Compiler zusätzliche Informationen zur Verfügung, so dass er zusätzliche Einschränkungen einführen und überprüfen kann, ob diese zur Kompilierungszeit erfüllt sind. Das Programm wird nicht kompiliert, wenn eine oder alle Beschränkungen nicht erfüllt sind. Auf diese Weise können Sie kostspielige
Builder-Muster in Java
Werfen wir zunächst einen Blick auf das Builder-Muster in Java:
public record Person(String firstName, String lastName, String email) {}
Der Builder selbst könnte etwa so aussehen:
package com.company;
public class PersonBuilder {
private String firstName;
private String lastName;
private String email;
public PersonBuilder firstName(String firstName) {
this.firstName = firstName;
return this;
}
private PersonBuilder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public PersonBuilder email(String email) {
this.email = email;
return this;
}
public firstName() {
return this.firstName;
}
public lastName() {
return this.lastName;
}
public email() {
return this.email;
}
public Person build() { // Optionally validate and throw exceptions for missing input
return new Person(firstName, lastName, email);
}
}
Wenn die Methode build aufgerufen wird, gibt es keine Möglichkeit zu garantieren, dass alle Felder der Person tatsächlich angegeben sind. Im obigen Code kann dies nur während der Laufzeit festgestellt werden.
Person person = new PersonBuilder()
.firstName("Hello")
.lastName("World")
.build(); // Oops, we forgot to specify the email
Sicherlich könnten Sie Ausnahmen einführen, um mit fehlenden Eingaben umzugehen, aber dann ist der Code nicht mehr referenztransparent, da Seiteneffekte auftreten könnten und Sie sich auch mit diesen befassen müssten.
Ein Ausdruck wird als referenziell transparent bezeichnet, wenn er durch seinen Wert ersetzt werden kann, ohne dass sich das Verhalten des Programms ändert.
Sie könnten sich dafür entscheiden, mehr Tests zu schreiben, da das Verhalten erst zur Laufzeit auffällt. Das ist jedoch nicht nötig, wenn der Scala-Compiler dies bereits von vornherein verhindern kann. Warum etwas testen, das bereits vom Compiler geschützt wird?
Builder-Muster in Scala
Lassen Sie uns den gesamten Code teilen und ihn dann Schritt für Schritt durchgehen:
import PersonBuilder.{Email, FirstName, FullPerson, LastName, PersonBuilderState}
case class Person(firstName: String, lastName: String, email: String)
object PersonBuilder {
sealed trait PersonBuilderState
sealed trait Empty extends PersonBuilderState
sealed trait FirstName extends PersonBuilderState
sealed trait LastName extends PersonBuilderState
sealed trait Email extends PersonBuilderState
type FullPerson = Empty with FirstName with LastName with Email
def apply(): PersonBuilder[Empty] = new PersonBuilder("", "", "")
}
class PersonBuilder[State <: personbuilderstate="" private="" val="" firstname:="" string="" lastname:="" email:="" def="" firstname="" personbuilder="" with="" new="" lastname="" email="" build="" ev:="" state=":=" fullperson="" person="{"/>
Wir können den Builder wie folgt verwenden:
val person = PersonBuilder()
.firstName("Hello")
.lastName("World")
.build // Oops, we forgot to specify the email
Wenn Sie versuchen, diesen Code zu kompilieren, wird dies nicht möglich sein:
Cannot prove that PersonBuilder.Empty with PersonBuilder.FirstName with PersonBuilder.* LastName =:= PersonBuilder.FullPerson
Wenn wir die Augen ein wenig zusammenkneifen, dann heißt es im Grunde genommen:
(Empty with FirstName with LastName) != FullPerson
Das ist korrekt, denn unsere Definition von FullPerson verlangt, dass wir eine E-Mail-Adresse angeben:
type FullPerson = Empty with FirstName with LastName with Email
Wenn Sie auch die E-Mail einbeziehen, werden Sie sehen, dass alles wieder gut kompiliert wird:
val person = PersonBuilder()
.firstName("Hello")
.lastName("World")
.email("hello@world.com") // By adding the e-mail, it will compile again
.build
Code-Erklärung
Wie funktioniert das Ganze also? In diesem Abschnitt gehen wir den Code durch und erläutern die Funktionsweise im Detail.
Zunächst definieren wir eine Reihe von Eigenschaften, die wir verwenden möchten:
sealed trait PersonBuilderState
sealed trait Empty extends PersonBuilderState
sealed trait FirstName extends PersonBuilderState
sealed trait LastName extends PersonBuilderState
sealed trait Email extends PersonBuilderState
In der folgenden Tabelle sehen Sie alle Zustände, die wir definiert haben.
| Staat | Beschreibung |
|---|---|
| Leere | Der Ausgangszustand (d.h. keine der Eigenschaften wurde eingestellt) |
| Vorname | Der erste Name wird festgelegt |
| Nachname | Der Nachname ist festgelegt |
| Die E-Mail ist eingestellt |
Zweitens führen wir einen neuen Typ ein, indem wir festlegen, dass ein FullPerson die Kombination aus einer Empty Person mit einem FirstName plus einem LastName plus einem Email ist.
type FullPerson = Empty with FirstName with LastName with Email
Als Nächstes erstellen wir eine Typklasse, in der wir eine generische Klasse verwenden und sie auf den Typ PersonBuilderState einschränken.
class PersonBuilder[State <: personbuilderstate=""/>
Darüber hinaus erweitern wir die State für jede der Methoden, um dem Compiler zusätzliche Typinformationen zur Verfügung zu stellen:
def firstName(firstName: String): PersonBuilder[State with FirstName]
Es nimmt die State und erweitert sie um FirstName. Wenn wir eine neue State zu Empty with FirstName. Wenn wir anschließend die E-Mail einstellen, wird aus State Empty with FirstName with Email . Wie Sie sehen können, spielt die Reihenfolge keine Rolle. Wir müssen nur sicherstellen, dass wir in dem Moment, in dem wir die Methode build aufrufen, eine FullPerson haben.
Das letzte Teil des Puzzles ist die eigentliche build Methode:
def build()(implicit ev: State =:= FullPerson)
Das magische Symbol hier ist =:=. Es wird verwendet, um Gleichheitsbeschränkungen auszudrücken. Mit anderen Worten, der Compiler muss beweisen, dass
Fazit
Und das war's im Grunde schon! Phantomtypen sind ein sehr leistungsfähiges Konzept, mit dem Sie Ihren Code viel robuster machen können. Sie ermöglichen es uns, Probleme in der frühestmöglichen Phase zu erkennen, nämlich bei der Kompilierung, die auch die kostengünstigste Phase zur Behebung von Problemen ist.
Verfasst von

Anton Lijcklama à Nijeholt
Contact



