Blog
Prägnante Konstruktoren mit undurchsichtigen Typen in Scala 3 entwerfen

Undurchsichtige Typen und Inline
Im weiten Reich der Programmiersprachen ist Scala eine vielseitige Wahl, und eines seiner oft unterschätzten Juwelen ist das Konzept der opaken Typen in Scala 3. Diese ermöglichen es Entwicklern, abstrakte Datentypen zu erstellen und dabei die Kapselung und Typsicherheit beizubehalten.
In diesem Beitrag werden wir die Feinheiten der Definition von apply und from Methoden im Kontext undurchsichtiger Typen erkunden, die es uns ermöglichen, die volle Leistungsfähigkeit des Typsystems von Scala zu nutzen.
Nehmen wir als Beispiel ein grundlegendes Datenmodell für ein Finanzinstitut.
final case class AccountHolder(firstName: String, middleName: Option[String], lastName: String, secondLastName: Option[String])
final case class Account(accountHolder: AccountHolder, iban: String, balance: Int)
Haftungsausschluss: Verwenden Sie einen geeigneteren Typ für die Balance in Ihrem Produktionscode. Verwenden Sie nicht Int.
Lassen Sie uns nun untersuchen, wie Sie diese Domäne mithilfe des Typsystems von Scala effektiver modellieren können.
Grundstufe
Auf der grundlegenden Ebene können Sie Typ-Aliase für die zugrunde liegenden Typen definieren. Dieser Ansatz verbessert die Lesbarkeit des Codes und verdeutlicht die Absicht:
type Name = String
type IBAN = String // International Bank Account Number
type Balance = Int
Mit diesen Typ-Aliasen wird das Grundmodell:
final case class AccountHolder(firstName: Name, middleName: Option[Name], lastName: Name, secondLastName: Option[Name])
final case class Account(accountHolder: AccountHolder, iban: IBAN, balance: Balance)
Dieser Ansatz ändert die API der zugrunde liegenden Typen nicht, verbessert aber die Lesbarkeit des Codes erheblich.
Weitere Informationen finden Sie im Blog von Alvin Alexander.
Standard Level
Scala 3 führt mit dem opaque-Schlüsselwort eine effizientere Methode zur Deklaration von Typen ein. Damit können Sie einen opaque-Typ definieren und den zugrundeliegenden Typ erst dann offenlegen, wenn der Code kompiliert ist.
opaque type Name = String
opaque type IBAN = String
opaque type Balance = Int
Um Instanzen von undurchsichtigen Typen zu erstellen, verwenden Sie die apply-Methode im Begleitobjekt:
object Name:
def apply(name: String): Name = name
object IBAN:
def apply(iban: String): IBAN = iban
object Balance:
def apply(balance: Int): Balance = balance
Dieser Ansatz erhält die Kapselung aufrecht und bietet eine sauberere Schnittstelle für die Erstellung von Instanzen undurchsichtiger Typen in Scala 3, was die Codesicherheit und Wartbarkeit verbessert.
Beispiel für die Standardstufe:
val firstName: Name = Name("John")
val middleName: Name = Name("Stuart")
val lastName: Name = Name("Mill")
val iban: IBAN = IBAN("GB33BUKB20201555555555")
val balance: Balance = Balance(123)
val holder: AccountHolder = AccountHolder(firstName, Some(middleName), lastName, None)
val account: Account = Account(holder, iban, balance)
Weitere Informationen finden Sie in der Scala 3 Dokumentation und im Blog von Alvin Alexander.
Fortgeschrittene
In realen Anwendungen müssen Sie oft mit Werten arbeiten, die zur Laufzeit nicht bekannt sind und eine spezielle Validierung erfordern.
Sie können dies erreichen, indem Sie dem Begleitobjekt eine from-Methode hinzufügen.
Mit dieser Methode können Sie undurchsichtige Typen in Scala 3 auf der Grundlage von Laufzeitwerten validieren und konstruieren:
final case class InvalidName(message: String) extends RuntimeException(message) with NoStackTrace
opaque type Name = String
object Name:
def from(fn: String): Either[InvalidName, Name] =
// Here we can access the underlying type API because it is evaluated during runtime.
if fn.isBlank | (fn.trim.length < fn.length)
then Left(InvalidName(s"First name is invalid with value <$fn>."))
else Right(fn)
Wir verwenden das Inline-Schlüsselwort vor def und vor if für Werte, die zum Zeitpunkt der Kompilierung bekannt sind.
Für eine bessere Fehlerverfolgung verwenden wir die im Paket scala.compiletimeverfügbare API:
codeOf(x)gibt den Wert des Parametersxerror(x)fügt die Zeichenkette x als Kompilierungsfehlermeldung ein+verkettet den Wert des Parametersxund den Rest der Fehlermeldungen
inline def apply(name: String): Name =
inline if name == ""
then error(codeOf(name) + " is invalid.")
else name
Diese Kombination aus Inline- und Compiletime-Tools ermöglicht eine umfassende Validierung während der Kompilierung und Laufzeit und gewährleistet die Robustheit Ihrer Datenmodelle.
Warum ist das Schlüsselwort inline hier so wichtig?
Sie veranlasst den Compiler, die rechte Seite dort zu ersetzen, wo die linke Seite aufgerufen wird.
Die inline if wertet die Bedingung während der Kompilierungszeit aus.
Falls wahr, wird die apply umgeschrieben als:
inline def apply(name: String) = error(codeOf(name) + " is invalid.")
Wenn wir also versuchen, etwas wie dieses zu schreiben:
val firstName: Name = Name("")
Sie ersetzt die rechte Seite von def apply (da sie ebenfalls inlined ist) während der Kompilierung durch:
val firstName: Name = error(codeOf("") + " is invalid.")
Und wir erhalten einen Compilerfehler:
[error] -- Error: /opaque_types_and_inline/03-advanced/src/main/scala/dagmendez/advanced/Main.scala:12:39
[error] 12 | val firstName: Name = Name("")
[error] | ^^^^^^^^
[error] | "" is invalid.
[error] one error found
[error] (advanced / Compile / compileIncremental) Compilation failed
Da wir nun die beiden Methoden apply und from verwenden, können wir bekannte und unbekannte Werte während der Kompilierung und zur Laufzeit validieren.
Aber ... die Validierung der Methode apply unterscheidet sich von der der Methode from. Und warum?
Ein if-then-else-Ausdruck, dessen Bedingung ein konstanter Ausdruck ist, kann auf die ausgewählte Verzweigung vereinfacht werden. Wenn Sie einem if-then-else-Ausdruck das Präfix inline voranstellen, erzwingen Sie, dass die Bedingung ein konstanter Ausdruck sein muss, und garantieren so, dass die Bedingung immer vereinfacht wird.
Die in der Methode from verwendeten Methoden werden zur Laufzeit ausgewertet und können daher nicht auf einen konstanten Ausdruck reduziert werden.
Wenn wir versuchen, dieselbe Validierung in der Methode apply zu kompilieren, wird der Compiler dies nicht zulassen.
Lesen Sie die vollständige Dokumentation zum Inlining unter Scala 3 Referenz für Metaprogrammierung.
Scala Magie
Wie implementieren wir verfeinerte Typen, die robust und wartbar sind?
Erstens muss der Validierungsalgorithmus robust sein und sollte für die Methoden apply und from gleich sein.
Zweitens sollten die Fehlermeldungen so ähnlich wie möglich sein, um Fehler während der Laufzeit leicht zu erkennen.
Lassen Sie uns also unsere raffinierten Typen nacheinander überprüfen.
Balance
Stellen Sie sich ein Szenario vor, in dem eine Bank bestimmte Grenzen für Kontoguthaben festlegt: ein Minimum von -1.000€ und ein Maximum von 1.000.000€. Wir möchten sicherstellen, dass die Validierung sowohl für die apply- als auch für die from-Methode gleich ist.
In diesem speziellen Fall können wir dieselbe Validierung in der apply-Methode verwenden, da die Bedingung zur Kompilierzeit ausgewertet werden kann. Wir deklarieren eine Inline-Methode, die den Saldo als Parameter erhält und einen booleschen Wert zurückgibt. Damit dies funktioniert, benötigen wir einen booleschen Ausdruck, der zur Kompilierzeit ausgewertet werden kann:
inline def validation(balance: Int): Boolean =
balance >= -1000 &&
balance <= 1000000
Was ist nun mit der Fehlermeldung?
Wir möchten, dass sie konsistent und leicht identifizierbar ist und während der Kompilierung auf eine einzige Zeichenfolge reduziert wird. Wir erreichen dies mit dem folgenden Code:
inline val errorMessage = " is invalid. Balance should be equal or greater than -1,000 and equal or smaller than 1,000,000"
So kann die Fehlermeldung inlined und auf eine einzige Zeichenkette reduziert werden. So können wir prägnante und aussagekräftige Fehlermeldungen erstellen, wie z.B.:
error(codeOf(balance) + errorMessage)
In der Methode from geben wir eine Verkettung des Parameters und der Fehlermeldung zurück, die in einer spezifischen Fehlerfallklasse verpackt ist.
Dieser Ansatz gewährleistet einen einheitlichen Fehlermechanismus:
Left(InvalidBalance(balance + errorMessage))
IBAN - Internationale Bankkontonummer
Die IBAN ist ein internationaler Standard für Bankkontonummern, und verschiedene Länder haben spezifische Regeln für IBAN-Formate. In unserem Fall verwenden wir die spanische Regel, die vorschreibt, dass eine IBAN immer mit dem Ländercode "ES" beginnt und eine Gesamtlänge von 26 Zeichen hat.
In der Methode from führen wir Laufzeitprüfungen durch, um sicherzustellen, dass die IBAN gültig ist.
def from(iban: String): Either[InvalidIBAN, IBAN] =
if
iban.substring(0, 2) == "ES" &&
iban.length == 26 &&
iban.substring(2, 25).matches("^\d*$")
then Right(iban)
else Left(InvalidIBAN(iban + errorMessage))
In der apply-Methode können wir Methoden wie substring oder length nicht verwenden, da sie zur Laufzeit ausgewertet werden. Scala 3 kommt uns mit dem scala.compiletime.ops-Paket zu Hilfe, das die benötigten Werkzeuge bereitstellt.
Die wahre Magie des Inlinings und der Kompilierzeit-API kommt zum Vorschein!
Wir verwenden das Schlüsselwort inline und das Makro constValue, um Prüfungen zur Kompilierungszeit durchzuführen.
Dieser Ansatz stellt sicher, dass die IBAN mit dem erforderlichen Format übereinstimmt:
inline def apply(iban: String): IBAN =
inline if constValue[
Substring[iban.type, 0, 2] == "ES" &&
Length[iban.type] == 26 &&
Matches[Substring[iban.type, 2, 25], "^\d*$"]
]
then iban
else error(codeOf(iban) + errorMessage)
Wir arbeiten hier auf der Typebene, also sehen wir uns die Übersetzungen an, die der Compiler vornimmt:
Substring[String, Int, Int]: gibt den Wert der Teilzeichenkette als TypStringzurück. Hier verwenden wiriban.type, weil wir mit Typen arbeiten, aber dieser Aufruf gibt nichtStringzurück, sondern den Wert selbst als einen wörtlicher Typ .
val iban: ES012345678901234567890123 = "ES012345678901234567890123"
val condition: Boolean = Substring[iban.type, 0, 2] == "ES"
val condition: Boolean = Substring[ES012345678901234567890123, 0, 2] == "ES"
val condition: Boolean = ES == "ES"
val condition: Boolean = "ES" == "ES" //ES is converted to its value
val condition: Boolean = true
Length[String]: Gibt die Länge der Zeichenkette als einIntval iban: ES012345678901234567890123 = "ES012345678901234567890123" val condition: Boolean = Length[iban.type] == 26 val condition: Boolean = Length[ES012345678901234567890123] == 26 val condition: Boolean = 26 == 26 //Type 26 is converted to its value val condition: Boolean = true
Ist das nicht magisch?
Name
Namen können eine komplexe Angelegenheit sein, insbesondere in Ländern, in denen die Menschen mehrere Vornamen haben und diese nicht als zweite Vornamen kategorisieren. Wir brauchen einen flexiblen und dennoch robusten Validierungsmechanismus. Für unseren verfeinerten Typ legen wir die folgenden Regeln fest:
- Namen sollten mit einem Großbuchstaben beginnen, gefolgt von Kleinbuchstaben.
- Vor oder nach dem Namen sollten keine Leerzeichen stehen.
- Namen können mehrere gültige Namen enthalten, die durch Leerzeichen getrennt sind.
Um Namen zu überprüfen, verwenden wir einen regulären Ausdruck gemäß den Java-Standards. Hier ein kleiner Einblick, wie wir dies erreichen:
object Name:
inline val validation = """^[A-Z][a-zA-Z]*(?:s[A-Z][a-zA-Z]*)*$"""
inline val errorMessage = " is invalid. It must: n - be trimmed.n - start with upper case.n - follow upper case with lower case."
inline def apply(fn: String): Name =
inline if constValue[Matches[fn.type, validation.type]]
then fn
else error(codeOf(fn) + errorMessage)
def from(fn: String): Either[InvalidName, Name] =
if validation.r.matches(fn)
then Right(fn)
else Left(InvalidName(fn + errorMessage))
Dieser Ansatz bietet uns eine gemeinsame Fehlermeldung und eine Validierungslogik, die in nur wenigen Zeilen Code elegant ausgedrückt wird.
Fazit
Die undurchsichtigen Typen von Scala, Inline und die API zur Kompilierzeit bieten uns die Möglichkeit, verfeinerte Typen zu definieren, die sowohl präzise als auch elegant sind. Das wirklich Magische daran ist, dass Sie keine externen Bibliotheken benötigen. Die Sprache Scala selbst gibt Ihnen die Werkzeuge an die Hand, um robuste und wartbare Datenmodelle zu erstellen.
Mit diesem Wissen können Sie Ihre Scala-Programmierkenntnisse verbessern und zuverlässigeren und aussagekräftigeren Code erstellen. Machen Sie sich also die Magie von Scala zunutze, um raffinierte Typen zu entwickeln, die sowohl robust als auch wartbar sind. Ihr Code wird es Ihnen danken!
Unsere Ideen
Weitere Blogs
Contact



