Blog

Generische Verfeinerungstypen in Scala 3

Lawrence Lavigne

Aktualisiert Oktober 15, 2025
16 Minuten

Ein Hauptziel der starken Typisierung in jeder Programmiersprache ist es, "illegale Zustände nicht darstellbar zu machen", wie Alexis King in seinem Artikel "Parse, don't validate" bemerkenswert formuliert. Zu diesem Zweck werden Verfeinerungstypen immer beliebter, um bestehende Typen einzuschränken, z.B. indem ein Ganzzahlentyp nur auf natürliche Zahlen beschränkt wird.

Was können Verfeinerungstypen in Scala für uns tun? Wie sich herausstellt, eine ganze Menge! Wenn ich mir meine eigenen Projekte ansehe, dann habe ich damit experimentiert:

  • Eine kleine Hierarchie von generischen numerischen Typen (NonZero, NonNegative und Positive) und eine Reihe von arithmetischen Operationen, die diese verwenden, wie z.B. "sichere" Division, Quadratwurzel und Berechnung von Vektorlängen.
  • Eine benutzerdefinierte Seq, die NonNegative[Int] als Index- und Zähltyp verwendet, mit einer NonEmptySeq Unterklasse, die einen Positive[Int] Zähltyp verwendet.
  • Eine Real Verfeinerung von Double, die Werte ausschließt, die keine reellen Zahlen sind (d.h. positive/negative Unendlichkeit und NaN), zusammen mit einer Reihe von "sicheren" trigonometrischen Funktionen, bei denen sich der Benutzer keine Sorgen um Eckfälle machen muss.
  • Unterschiedliche Typen für Positions- und Bewegungsvektoren in einer Spielwelt, mit Operationen, die erzwingen, wie sie kombiniert werden können (zum Beispiel können wir zwei Bewegungen addieren oder eine Position um eine Bewegung verschieben, aber nicht versehentlich zwei Positionen addieren).

In diesem Artikel werden wir das erste dieser Beispiele implementieren. Dabei verwenden wir die undurchsichtigen Typ-Aliase von Scala 3 sowie Inlining und andere Operationen zur Kompilierzeit, um unsere numerischen Typen und typsicheren Operationen zu erstellen. Wir werden diese opaken Typen generisch und höherwertig machen, so dass sie eine Vielzahl von Basistypen einschränken können.

Wir werden uns darauf konzentrieren, die Typsicherheit von nicht konstanten Werten zu maximieren, während sie unsere verschiedenen Operationen durchlaufen. Verfeinerungstypen eignen sich aber auch hervorragend für den Umgang mit Konstanten zur Kompilierzeit. Ein hervorragendes Tutorial zu diesem Thema und mehr ist "Crafting Concise Constructors with Opaque Types in Scala 3" von meinem Xebia-Kollegen David A. Gil Méndez.

Verwendung des Scastie Online-Editors

In den folgenden Codebeispielen verwende ich Scala 3.5.2 und den Scastie Online-Editor im Worksheet-Modus. Dieser Modus (die Standardeinstellung) ermöglicht Deklarationen auf oberster Ebene, als ob wir innerhalb einer Hauptmethode kodieren würden, und zeigt Inlay-Typ-Hinweise für alle Deklarationen an, ähnlich wie die Scala REPL die Typen nach jeder Zeile ausgibt. Den Link zum fertigen Arbeitsblatt finden Sie nach dem letzten Codebeispiel, kurz vor dem Abschnitt über die Schlussfolgerungen.

Beachten Sie, dass Paketdeklarationen im Scastie-Arbeitsblatt nicht erlaubt sind. Wenn ich mit undurchsichtigen Typ-Aliasen arbeite, verwende ich ein Scala-Objekt, um deren Transparenz zu gewährleisten. In einem normalen Projekt würden wir stattdessen ein Paket verwenden, oder sogar nur Top-Level-Deklarationen in einer separaten Quelldatei.

Die Grundlagen schaffen

Fangen wir an! Da wir mehrere generische Verfeinerungstypen erstellen werden, sollten wir eine praktische Basiseigenschaft für ihre Begleitobjekte definieren:

trait Refinement[R[_]]:
  protected def refined[T](x: T): R[T]
  def isValid[T](x: T): Boolean
  def apply[T](x: T): R[T] =
    require(isValid(x), s"invalid $x")
    refined(x)
  def option[T](x: T): Option[R[T]] =
    if isValid(x) then Some(refined(x)) else None

In diesem Merkmal haben wir einen höherwertigen R[_] Typ, ein isValid Methode, um zu prüfen, ob ein Wert von T entspricht unserer Verfeinerung, und eine geschützte refined Methode, um einen validierten Wert von T in eine raffinierte R[T]. Diese haben wir dann in die öffentlichen Methoden apply und option verpackt, um sicherzustellen, dass alle Werte von R[T] gültig sind.

Unser erster Verfeinerungstyp

Definieren wir unseren ersten Verfeinerungstyp für Nicht-Null-Werte. Da es sich bei unseren Typen um generische Typen handelt, müssen wir zunächst eine Typklasse definieren, die das "Null"-Element von T aufnimmt, um es zu validieren:

final case class Zero[T](e: T)
def zero[T: Zero]: T = summon[Zero[T]].e

Deklarieren wir eine generische gegebene Instanz von Zero für Scalas
Numeric
Typen:

given [T: Numeric]: Zero[T] = Zero(Numeric[T].zero)

Dadurch erhalten wir Instanzen für gängige Typen wie Int, Double usw.

Unseren Typ definieren

Das war's. Hier ist unser erster Entwurf für den Typ NonZero:

object refinements:
  opaque type NonZero[T] <: T = T
  object NonZero extends Refinement[NonZero]:
    override protected def refined[T](x: T): NonZero[T] = x
    override def isValid[T](x: T): Boolean = x != zero

Hier haben wir einen(parametrisierten) undurchsichtigen Typ-Alias verwendet, der zur Laufzeit intern durch einen Wert von T dargestellt wird, ohne Wrapping oder Boxing. Diese Identität ist innerhalb des refinements Objekts transparent, so dass wir in unserer refined Implementierung einfach x selbst zurückgeben können. Außerhalb des Geltungsbereichs von refinements wird NonZero[T] als eigenständiger Typ betrachtet. Da er aber T als Typbindung hat, können wir seine Werte überall dort verwenden, wo ein T erwartet wird.

Inlining unserer Methoden

Vielleicht ist Ihnen ein Problem in unserer Implementierung von isValid aufgefallen: Sie benötigt einen impliziten Parameter für die Instanz NonZero, und in der geerbten Signatur gibt es keinen solchen Parameter. Der Code lässt sich nicht kompilieren, wenn er fehlt (keine Zero Instanz gefunden) und er lässt sich nicht kompilieren, wenn er hinzugefügt wird (da die abstrakte Methode der Eltern nicht implementiert würde). Wir wollen sie auch nicht zu der übergeordneten Signatur hinzufügen, da dies alle unsere Verfeinerungstypen an Zero binden würde.

Wie können wir dieses Rätsel also lösen? Es hat sich herausgestellt, dass wir den Override inline markieren und mit scala.compiletime.summonInline so dass eine Instanz von Zero am Aufrufort erforderlich ist, auch wenn sie nicht in der Signatur der Methode enthalten ist. (Sie verwendet dieselben impliziten Auflösungsregeln wie eine reguläre summon, in diesem Fall die Auflösung zu unserer Numeric-abgeleiteten Instanz.) Nachdem wir also das Inlining an den vom Compiler geforderten Stellen weitergegeben haben, wird unser Code zu einem:

import scala.compiletime.*

trait Refinement[R[_]]:
  protected def refined[T](x: T): R[T]
  inline def isValid[T](x: T): Boolean
  inline def apply[T](x: T): R[T] =
    require(isValid(x), s"invalid $x")
    refined(x)
  inline def option[T](x: T): Option[R[T]] =
    if isValid(x) then Some(refined(x)) else None
end Refinement

final case class Zero[T](e: T)
inline def zero[T]: T = summonInline[Zero[T]].e

object refinements:
  opaque type NonZero[T] <: T = T
  object NonZero extends Refinement[NonZero]:
    override protected def refined[T](x: T): NonZero[T] = x
    override inline def isValid[T](x: T): Boolean = x != zero
end refinements

given [T: Numeric]: Zero[T] = Zero(Numeric[T].zero)

Unser Typ testen

Lassen Sie uns nun unseren NonZero Typ testen, indem wir ihn als Divisor-Parameter einer "sicheren Divisionsfunktion" verwenden:

import refinements.*

def quotient(a: Int, b: NonZero[Int]): Int = a / b

quotient(4, 0)

Da wir uns außerhalb des refinements Objekts befinden, wird der Aufruf quotient(4, 0) nicht kompiliert, da 0 ein Int ist und wir ein NonZero[Int] benötigen.

Jetzt wollen wir sehen, ob wir die Methode so austricksen können, dass sie 0 als NonZero Wert akzeptiert:

quotient(4, NonZero(0))

Hier schlägt der Aufruf von apply mit einer ExceptionInInitializerError fehl, die durch "IllegalArgumentException: invalid 0" verursacht wird. (In Scastie ist der Initialisierungsfehler möglicherweise nicht sofort ersichtlich; nachdem Sie auf Ausführen geklickt haben, suchen Sie nach einem roten Schnörkel ganz unten im Arbeitsblatt). Beachten Sie, dass der Entwickler in einer realen Anwendung diese apply() Methode, die eine Ausnahme auslöst, normalerweise nicht aufrufen wird. Stattdessen verwendet er option() für das Parsing/die Validierung an den Rändern des Systems und lässt den Typ dann ordnungsgemäß durch die von uns definierten Operationen übertragen und transformieren.

Rufen wir nun unsere Methode mit einem gültigen NonZero Wert auf:

quotient(4, NonZero(2))

Dies ergibt den erwarteten Int Wert von 2.

Hinzufügen weiterer Typen

Lassen Sie uns nun einen weiteren Verfeinerungstyp in unserem refinements Objekt erstellen, für nicht-negative (Null oder größer) Werte:

  opaque type NonNegative[T] <: T = T
  object NonNegative extends Refinement[NonNegative]:
    override protected def refined[T](x: T): NonNegative[T] = x
    override inline def isValid[T](x: T): Boolean =
      summonInline[Ordering[T]].gteq(x, zero)

Hier benötigt unsere Methode isValid Typklasseninstanzen sowohl von unserer Zero als auch von Scalas eigenem Ordering, also wenden wir wieder den Trick summonInline an und prüfen, ob unser Wert größer oder gleich zero ist.

Wir können diese neue Verfeinerung verwenden, um unserer Funktion zero selbst einen genaueren Typ zu geben, wenn wir sie innerhalb des refinements Objekts verschieben:

object refinements:
  inline def zero[T]: NonNegative[T] = summonInline[Zero[T]].e

Lassen Sie uns schließlich noch eine Verfeinerung für positive Werte (streng größer als Null) erstellen. Das ist interessant: Es handelt sich um die Kombination von Nicht-Null und Nicht-Negativ... Könnten wir diese Kombination auf eine ausdrucksvollere Weise darstellen als mit einem undurchsichtigen Typ? Wie sich herausstellt, bietet Scala 3 eine andere Möglichkeit: Schnittpunkttypen!

  type Positive[T] = NonZero[T] & NonNegative[T]
  object Positive extends Refinement[Positive]:
    override protected def refined[T](x: T): Positive[T] = x
    override inline def isValid[T](x: T): Boolean =
      NonZero.isValid(x) && NonNegative.isValid(x)

Hier ist unser Typ Positive nicht undurchsichtig, sondern einfach eine Schnittmenge aus NonZero und NonNegative. Wir sind immer noch in der Lage, die T Wert so wie er ist in unserem refined Implementierung, denn wir befinden uns innerhalb der refinements Objekt, wobei beide NonZero[T] und NonNegative[T] sind Aliasnamen von T und somit reduziert sich ihre Schnittmenge auf T. Und genau wie bei unseren gebundenen undurchsichtigen Typen können wir ein Positive[T] überall dort übergeben, wo ein T, NonZero[T] oder NonNegative[T] benötigt wird.

Operationen definieren

Die Verwendung undurchsichtiger Typen, die die Validierung kapseln und erzwingen, trägt wesentlich dazu bei, dass illegale Zustände nicht mehr dargestellt werden können, wie wir in der Einleitung zitiert haben. Da sich unsere Verfeinerungen jedoch auf Zahlen konzentrieren, möchten wir mit ihnen arithmetische Operationen durchführen, ohne dass wir die Ergebnisse ständig neu verpacken müssen. Lassen Sie uns sehen, ob wir einige Operationen definieren können, mit denen dies möglich ist.

Unsere erste "verfeinerte" Operation

Lassen Sie uns mit einer einfachen binären Operation beginnen: der Addition.

Was sollte der Rückgabetyp dieser Funktion sein? Schauen wir mal:

  • Die Addition eines Positivs und eines Nicht-Negativs (oder umgekehrt, kommutativ) sollte ein Positiv ergeben;
  • Die Addition von zwei nicht-negativen Zahlen (von denen nicht bekannt ist, dass sie positiv sind) sollte eine nicht-negative Zahl ergeben.
  • Bei anderen Eingabetypen wie Zahlen, die nicht Null sind, können wir nichts über das Ergebnis garantieren; zum Beispiel ergibt die Addition von 2 und -2 0, was außerhalb unserer Verfeinerungen liegt.

Hier ist also unser erster Entwurf:

trait Addition[T <: Matchable]:
  protected def sumOf(x: T, y: T): T
  extension(x: T)
    def ++(y: T): T = (x, y) match
      case (_:    Positive[T], _: NonNegative[T]) => Positive(sumOf(x, y))
      case (_: NonNegative[T], _:    Positive[T]) => Positive(sumOf(x, y))
      case (_: NonNegative[T], _: NonNegative[T]) => NonNegative(sumOf(x, y))
      case _                                      => sumOf(x, y)

Wie bei unserer Eigenschaft Refinement haben wir eine geschützte "unsichere" Methode definiert, die der Benutzer in Form von T implementieren kann, und diese in eine öffentliche(Erweiterungs-)Infix-Operator-Methode mit dem Namen ++ eingepackt (mehr zur Wahl des Namens weiter unten). Diese Methode implementiert unsere Verfeinerungslogik unter Verwendung einer Musterübereinstimmung, wobei sie darauf achtet, spezielle Fälle zu behandeln, bevor sie auf die allgemeineren Fälle zurückgreift. Gemäß den modernen Scala 3 Best Practices muss das Argument T-typisiert sein. Matchable, die die meisten Wert- und Referenztypen erfüllen werden.

Dadurch erhalten wir den korrekten Laufzeittyp für unseren Rückgabewert, aber es ändert nicht seinen Kompiliertyp. Lassen Sie uns zum Beispiel eine Addition[Int] Instanz deklarieren und dann versuchen, das Ergebnis an unsere quotient Funktion zu übergeben:

given Addition[Int] with
  override protected def sumOf(x: Int, y: Int): Int = x + y

val x = Positive(1)
val y = Positive(2)
val q = quotient(9, x ++ y)

Wir stellen fest, dass dies fehlschlägt, weil x ++ y vom einfachen Typ Int ist und nicht der von quotient erwartete NonZero[Int].

Was tun wir also? Mehr Inlining! Dieses Mal verwenden wir die transparent inline Modifikator zusammen mit einer Inline-Übereinstimmung, so dass der inlined Code von ++ den spezifischen Rückgabetyp der ausgewählten Verzweigung hat.

trait Addition[T <: Matchable]:
  protected def sumOf(x: T, y: T): T
  extension(x: T)
    transparent inline def ++(y: T): T = inline (x, y) match
      case (_:    Positive[T], _: NonNegative[T]) => Positive(sumOf(x, y))
      case (_: NonNegative[T], _:    Positive[T]) => Positive(sumOf(x, y))
      case (_: NonNegative[T], _: NonNegative[T]) => NonNegative(sumOf(x, y))
      case _                                      => sumOf(x, y)

Mit dieser Syntax können wir den obigen Testcode wiederholen und sehen, dass er funktioniert.

Auswahl unserer Operator-Symbole

Beachten Sie, dass wir den Additionsoperator mit zwei Pluszeichen definieren (++). Warum nicht den bestehenden + Operator außer Kraft setzen? Das würden wir gerne tun, aber leider haben in Scala eingebaute Operatoren für primitive Typen Vorrang, selbst wenn diese als (lokal undurchsichtige) undurchsichtige Typen aliiert sind. Eine Verfeinerung von Int, wie z.B. Positive[Int], wird also den eingebauten Operator + anstelle unseres Overrides verwenden und in jedem Fall ein einfaches Int zurückgeben. Die Überschreibung würde in Kontexten verwendet, in denen die Verfeinerung einen generischen T Typ umhüllt, der zur Kompilierungszeit nicht bekannt ist, aber die Unterscheidung zwischen diesen beiden Szenarien würde dem Benutzer leicht entgehen und zu Verwirrung führen.

Eine Folgefrage könnte lauten: Warum wählen wir ++ und nicht etwas anderes wie |+|, das für Halbgruppen in Cats verwendet wird? Wir tun dies, um von Scalas bestehenden Regeln für den Vorrang von Operatoren zu profitieren, die auf dem ersten Zeichen des Operators beruhen.

Hinzufügen weiterer Operationen

Lassen Sie uns dies mit der Multiplikation fortsetzen, die dem gleichen Muster folgt wie die Addition, aber mit ihren eigenen Verfeinerungsregeln:

trait Multiplication[T <: Matchable]:
  protected def productOf(x: T, y: T): T
  extension(x: T)
    transparent inline def **(y: T): T = inline (x, y) match
      case (_:    Positive[T], _:    Positive[T]) => Positive(productOf(x, y))
      case (_: NonNegative[T], _: NonNegative[T]) => NonNegative(productOf(x, y))
      case (_:     NonZero[T], _:     NonZero[T]) => NonZero(productOf(x, y))
      case _                                      => productOf(x, y)

In dieser Typklasse fügen wir auch eine squared Operation hinzu, die sich die Annahme zunutze macht, dass alle Quadrate nicht-negativ sind (d.h. wir haben es hier nicht mit komplexen Zahlen zu tun):

    transparent inline def squared: NonNegative[T] = inline x match
      case _: Positive[T] => Positive(productOf(x, x))
      case _              => NonNegative(productOf(x, x))

Und da wir gerade von Quadraten sprechen, lassen Sie uns noch eine Quadratwurzel-Operation hinzufügen:

trait SquareRoot[T <: Matchable]:
  protected def squareRootOf(x: T): T
  extension(x: NonNegative[T])
    transparent inline def squareRoot: NonNegative[T] = inline x match
      case _: Positive[T] => Positive(squareRootOf(x))
      case _              => NonNegative(squareRootOf(x))

Beachten Sie, dass diese Operation die wichtigste (nicht-negative) Quadratwurzel zurückgibt und daher die NonNegative Verfeinerung sowohl in der Eingabe als auch in der Ausgabe verwendet.

Bereitstellung bestimmter Instanzen

Wie bei unserer Zero Typklasse können wir den bestehenden Numeric Typ von Scala nutzen, um generische Typklasseninstanzen für die gängigen primitiven Typen zu definieren. Wir verwenden die Kurzschreibweise Single Abstract Method (SAM):

given [T <: Matchable: Numeric]: Addition[T] = Numeric[T].plus(_, _)

given [T <: Matchable: Numeric]: Multiplication[T] = Numeric[T].times(_, _)

given SquareRoot[Double] = math.sqrt(_)

Wie wir sehen, ist unser SquareRoot spezifisch für Double, da es keine solche Operation für ganzzahlige Typen wie Int gibt. (Scala bietet eine Fractional Typklasse, die eine Divisionsoperation definiert, aber keine Quadratwurzel).

Alles zusammenfügen

Sehen wir uns an, wie wir unsere Typen und Operationen verwenden können, um eine Operation zu definieren und zu verwenden, die die "Länge" eines Wertes im Sinne seines "Abstands von Null" zurückgibt. Das bedeutet den absoluten Wert für Skalare, die euklidische Länge für Vektoren usw. Um Verwechslungen mit anderen Begriffen für die Länge zu vermeiden, wie z.B. der Anzahl der Elemente in einer Sammlung, nennen wir sie Norm nach dem mathematischen Begriff.

trait Norm[T <: Matchable, N]:
  protected def normOf(x: T): N
  extension(x: T)
    transparent inline def norm: NonNegative[N] = inline x match
      case _: NonZero[T] => Positive(normOf(x))
      case _             => NonNegative(normOf(x))

Beachten Sie, dass dies unsere erste Operation mit zwei verschiedenen Typen ist, T für den Wert und N für die Norm. Das Grundprinzip ist jedoch dasselbe, und unsere Verfeinerungen hier drücken einfach aus, dass ein Wert, von dem (zur Kompilierungszeit) bekannt ist, dass er nicht Null ist, eine positive "Länge" hat, andernfalls, wenn er nur möglicherweise nicht Null ist, geben wir stattdessen einen nicht negativen Wert zurück.

Hier ist unsere Absolutwert-Instanz für skalare Numeric Werte, wobei der Wert und die Norm denselben Typ haben:

given [T <: Matchable: Numeric]: Norm[T, T] = Numeric[T].abs(_)

Für die Vektorinstanzen verwenden wir einfache Tupel von T, um die Vektoren zu repräsentieren, in diesem Beispiel mit 2 und 3 Dimensionen. Lassen Sie uns zunächst die Zero Instanzen implementieren, indem wir sie einfach von ihren Komponenten ableiten:

given [T: Zero]: Zero[(T, T)] = Zero(zero, zero)
given [T: Zero]: Zero[(T, T, T)] = Zero(zero, zero, zero)

Lassen Sie uns nun ihre Norm Instanzen implementieren, indem wir unsere bestehenden Verfeinerungstypen und -operationen nutzen:

given [T <: Matchable: Addition: Multiplication: SquareRoot: Zero: Ordering]: Norm[(T, T), T] =
  v => (v._1.squared ++ v._2.squared).squareRoot

given [T <: Matchable: Addition: Multiplication: SquareRoot: Zero: Ordering]: Norm[(T, T, T), T] =
  v => (v._1.squared ++ v._2.squared ++ v._3.squared).squareRoot

Die Composite Context Bound unseres Traits mag auf den ersten Blick etwas abschreckend wirken, aber sie ist ein Beispiel für das "Prinzip der geringsten Leistung", denn sie erfordert genau die Operationen, die sie zur Erfüllung ihrer Aufgabe benötigt, nicht mehr und nicht weniger. (Es ist ein bisschen so, als würde man in einer Bibliothek wie Cats das "Minimum" eines Funktors, Anwendungsvermögens, einer Monade usw. verlangen). Die Grenzen von Zero und Ordering sind etwas weniger offensichtlich, aber denken Sie daran, dass unsere Operationen sie intern verwenden, um ihre Ergebnisse zu validieren, wenn sie in Verfeinerungen verpackt werden.

Was die Implementierungen betrifft, so verwenden wir die standardmäßige "Quadratwurzel aus der Summe der Quadrate", um die euklidische Norm zu ermitteln. Beachten Sie, dass wir die Quadratsumme nicht durch den NonNegative Wrapper leiten müssen, damit squareRoot sie akzeptiert: Das liegt daran, dass squared immer ein NonNegative zurückgibt und unser transparent-inline ++ Operator "weiß", dass die Addition von zwei Nicht-Negativen auch ein Nicht-Negativ ergibt.

Erinnern Sie sich an die Norm Eigenschaft, dass wir das Ergebnis unserer Angabe auch auf Positive verfeinern, wenn unser Wert als NonZero bekannt ist. Lassen Sie uns das testen:

val v1 = (1.0, 2.0)
val v1Norm: NonNegative[Double] = v1.norm

val v2 = NonZero((1.0, 2.0))
val v2Norm: Positive[Double] = v2.norm

Und einige Gegenbeispiele:

val v3 = NonZero((1, 2))
val v3Norm: Positive[Int] = v3.norm // fails to compile: no squareRoot instance

val v4 = NonZero((0.0, 0.0)) // fails at runtime in the NonZero call
val v4Norm: Positive[Double] = v4.norm // never reached

Damit ist unser erster Streifzug durch generische Verfeinerungen abgeschlossen! Das ausgefüllte Scastie-Arbeitsblatt finden Sie hier.

Fazit

In diesem Artikel haben wir nur an der Oberfläche der Möglichkeiten von generischen verfeinerten Typen gekratzt. Es gibt Bibliotheken, die ein vollwertiges Verfeinerungs-Framework bieten, wie z.B. die klassische refined library und die neuere alternative iron, die Sie sich beide ansehen sollten. Aber auch ein einfaches selbst entwickeltes Setup, wie wir es hier definiert haben, kann eine Grundlage für interessante Entwicklungen bieten. Ich hoffe, Sie können sich vorstellen, wie Sie undurchsichtige Verfeinerungstypen (generisch oder nicht) in Ihren realen Projekten einsetzen können. Und ich würde mich freuen, von Ihnen zu hören - zögern Sie nicht, mich bei Fragen oder Kommentaren zu kontaktieren!

Verfasst von

Lawrence Lavigne

Contact

Let’s discuss how we can support your journey.