Blog
Warum zuerst kontextuelle Abstraktionen in Scala 3 lernen?

TL;DR: Scala 2 ist laut der Scala-Entwicklerumfrage immer noch die am häufigsten verwendete Version von Scala. Das bedeutet, dass die Kontextabstraktionen Scala-Entwicklern, insbesondere Neulingen, immer noch Kopfzerbrechen bereiten. In diesem Blog werden kontextbezogene Abstraktionen vorgestellt, zunächst aus der Sicht von Scala 3, das von der Überarbeitung der API profitiert. Dann werden wir die Intuition der kontextuellen Abstraktionen mit einigen Codebeispielen verdeutlichen und mit der Beziehung zu Scala 2 abschließen. So können Sie die Designprinzipien der kontextuellen Abstraktionen von Scala 3 bei der Arbeit an Scala 2-Codebases nutzen, um Fallstricke zu vermeiden und Code Smells zu erkennen, aber auch um sie leichter zu verdauen.
Was sind kontextuelle Abstraktionen?
Das Implizite von Scala ist eine der wichtigsten Funktionen der Sprache. Es ist das grundlegende Werkzeug zur Abstraktion über den Kontext und hat viele Anwendungsfälle wie die Implementierung von Typklassen, Injektion von Abhängigkeiten, Ausdruck von Fähigkeiten, Berechnung neuer Typen und mehr. Andere beliebte Sprachen wie Haskell, Rust und Swift implementieren die gleiche Idee.
Der Kern der Intuition für kontextuelle Abstraktionen ist die Idee der Begriffsinferenz: Wenn ein Typ gegeben ist, kann der Compiler ein Standardobjekt erstellen, das diesen Typ hat.
Kritiken
- Sie sind zu mächtig, und man kann sie leicht missbrauchen oder überstrapazieren.
- Der Missbrauch von
implicitImporten führt oft zu schwer nachvollziehbaren Fehlern. - Die Syntax der kontextuellen Abstraktionen ist minimal; sie besteht aus einem einzigen
implicitModifikator, der an viele Sprachkonstrukte angehängt werden kann. Das bedeutet, dass die Verwendung für Neulinge verwirrend ist. - Kontextfunktionen stellen eine Herausforderung für Tools wie Scaladocs dar, die auf statischen Webseiten beruhen.
- Fehlgeschlagene Suchen mit Kontextfunktionen liefern wenig hilfreiche Meldungen, insbesondere wenn eine tief rekursive implizite Suche fehlgeschlagen ist.
Keines davon stellt jedoch ein fatales Problem dar, denn die heutigen Scala-Entwickler haben sich daran gewöhnt. Dieser Status Quo stellt eine große Hürde für Normalsterbliche dar.
Was ist neu in Scala 3
Scala 3 führt vier grundlegende Änderungen für kontextuelle Abstraktionen ein:
- Gegebene Instanzen : Neue Methode zur Definition "kanonischer" Werte eines Typs.
-
Klauseln verwenden
: Dies bedeutet
implicitArgumente, die mit dem Schlüsselwortusingdefiniert wurden. - Gegebene Importe : Eine spezielle Form des Import-Wildcard-Selektors wird verwendet, um bestimmte Instanzen zu importieren.
-
Implizite Umrechnungen
: Implizite Konvertierungen werden durch bestimmte Instanzen der Klasse
scala.Conversiondefiniert.
Diese Designgrundlagen vermeiden Interaktionen zwischen anderen Sprachfunktionen und sorgen für mehr Orthogonalität, da sie eine bessere Begriffsinferenz von anderen Sprach-APIs trennen. Um es auf den Punkt zu bringen, besteht die Überarbeitung der API aus (nicht umfangreichen):
- Eine einzige Möglichkeit,
givensmit einemextensionSchlüsselwort für die Methodenerweiterung zu definieren, und es gibt eine einzige Möglichkeit, implizite Parameter und Argumente einzuführen
trait Ord[T]:
def compare(x: T, y: T): Int
extension (x: T)
def < (y: T) = compare(x, y) < 0
def > (y: T) = compare(x, y) > 0
given intOrd: Ord[Int] with
def compare(x: Int, y: Int) =
if x < y then -1 else if x > y then +1 else 0
given listOrd[T](using ord: Ord[T]): Ord[List[T]] with
def compare(xs: List[T], ys: List[T]): Int = (xs, ys) match
case (Nil, Nil) => 0
case (Nil, _) => -1
case (_, Nil) => +1
case (x :: xs1, y :: ys1) =>
val fst = ord.compare(x, y)
val extensionUsage = x > y
if fst != 0 then fst else compare(xs1, ys1)
- Es gibt eine einzige Möglichkeit, implizite Werte zu importieren
object A:
class TC
given tc: TC = ???
def f(using TC) = ???
object B:
import A.*
import A.given
- Und für die verwendeten und missbrauchten impliziten Konvertierungen, prägnante Absicht
import scala.Conversion
abstract class Conversion[-T, +U] extends (T => U):
def apply (x: T): U
given Conversion[String, Token] with
def apply(str: String): Token = new KeyWord(str)
given Conversion[String, Token] = new KeyWord(_)
Code Beispiele
Lassen Sie uns nun tiefer in die kontextuellen Abstraktionen eintauchen, indem wir mit etwas Code und Typeclasses herumspielen. Wenn Sie alle Details erfahren möchten, sollten Sie das fp-fundamentals Repo klonen, um besser folgen zu können.
Schritt 0: Gerüstbau
Wie Sie sehen können, ist der folgende Code zwingend erforderlich und gibt das Konto balance auf der Konsole aus. Wir werden funktionale Programmierung und Typklassen verwenden, um diesen Codeschnipsel zu verbessern.
@main def hello: Unit =
def getBalanceBank1: Int = 100
def getBalanceBank2: Int = 80
def balance: Int =
val b1: Int = getBalanceBank1
val b2: Int = getBalanceBank2
b1 + b2
// EDGE OF THE WORLD
println(balance)
Schritt 1: Datentyp Maybe erstellen
Lassen Sie uns einen Maybe Datentyp erstellen, um die Abfrage über die Methode def getBalance... besser auszudrücken. Lassen Sie uns auch das Programm aktualisieren.
sealed trait Maybe[+A]
case class Yes[A](a: A) extends Maybe[A]
case object No extends Maybe[Nothing]
@main def hello: Unit =
- def getBalanceBank1: Int = 100
- def getBalanceBank2: Int = 80
+ def getBalanceBank1: Maybe[Int] = Yes(100)
+ def getBalanceBank2: Maybe[Int] = Yes(80)
def balance: Int =
- val b1: Int = getBalanceBank1
- val b2: Int = getBalanceBank2
b1 + b2
+ val b1: Maybe[Int] = getBalanceBank1
+ val b2: Maybe[Int] = getBalanceBank2
b1 + b2 // it will fail
// EDGE OF THE WORLD
println(balance)
Schritt 2: Erstellen Sie die Typklasse Combinable
Wir erstellen eine kombinierbare Typklasse, um die zuvor definierte Maybe Typklasse zu kombinieren. In dem Instanzen-Objekt werden wir die given Instanzen erstellen.
trait Combinable[A]:
def combine(x: A, y: A): A
given Combinable[Int] with
override def combine(x: Int, y: Int): Int = x + y
given [A: Combinable]: Combinable[Maybe[A]] with
override def combine(x: Maybe[A], y: Maybe[A]): Maybe[A] = (x, y) match {
...
}
Und jetzt wird unser Code funktionieren, indem wir das Programm auf summon[Combinable[Maybe[Int]]] aktualisieren.
+ package training
+ import training.DataTypes.*
+ import training.Typeclases.*
+ import training.Instances.given
@main def hello: Unit =
def getBalanceBank1: Maybe[Int] = Yes(100)
def getBalanceBank2: Maybe[Int] = Yes(80)
- def balance: Int =
- val b1: Maybe[Int] = getBalanceBank1
- val b2: Maybe[Int] = getBalanceBank2
- b1 + b2 // it will fail
+ val b1: Maybe[Int] = getBalanceBank1
+ val b2: Maybe[Int] = getBalanceBank2
+ val value: Maybe[Int] = summon[Combinable[Maybe[Int]]].combine(b1, b2)
// EDGE OF THE WORLD
println(balance)
// println(balance)
Schritt 3: Syntax für Combinable
Lassen Sie uns sehen, wie wir eine Erweiterungsmethode erstellen können. Früher, in Scala 2, haben Sie etwas deklariert wie:
implicit class CircleDecorator(c: Circle) extends AnyVal {
def circumference: Double = c.radius * math.Pi * 2
}
Jetzt verwenden wir das Schlüsselwort extension. Beachten Sie, dass der Compiler etwas Ähnliches wie im letzten Beispiel kompilieren wird. Sie als Benutzer werden jedoch davon abstrahiert.
extension [A: Combinable](aMaybe: Maybe[A])
def +(bMaybe: Maybe[A]): Maybe[A] = (aMaybe, bMaybe) match
Dann aktualisieren wir unsere Program.scala
- val value: Maybe[Int] = summon[Combinable[Maybe[Int]]].combine(b1, b2)
val value: Maybe[Int] = b1 + b2
Jetzt kümmert sich die Syntax für uns um die Beschwörung, indem sie die Erweiterungsmethode verwendet. Großartig! Lassen Sie uns weitermachen. Es gibt noch eine Menge zu tun.
Schritt 4: Erstellen Sie einen Typklassen-Transformer (mit Syntax)
Dies ist die neue Typklasse, die wir einführen werden und die uns helfen wird, jedem Wert, der in einer bestimmten Typklasse enthalten ist, Transform-Eigenschaften hinzuzufügen.
trait Transformable[F[_]]:
def map[A, B](fa: F[A], f: A => B): F[B]
// lets add an extension method
extension [A](self: Maybe[A])
def map[B](f: A => B): Maybe[B] = summon[Transformable[Maybe]].map(self, f)
Und jetzt können wir map unsere Werte in unserem Program.scala
- val b1: Maybe[Int] = getBalanceBank1
+ val b1: Maybe[Int] = getBalanceBank1.map(_.balance)
Schritt 5: Erstellen Sie eine bessere Version der vorherigen Typklasse.
Wir werden unserer Domain Statement eine neue case class hinzufügen, da String nicht mehr ausreicht.
@main def hello: Unit =
case class Account(id: String, balance: Int)
+ case class Statement(isRich: Boolean, accounts: Int)
...
Wir können nun eine Statement statt einer Bilanz in unserer Program.scala erstellen.
- val balance: Maybe[Int] = b1 + b2
+ def statement =
+ summon[Transformable[Maybe]].map2(b1, b2, (x: Int, y: Int) => Statement(x + y > 1000, 2))
// EDGE OF THE WORLD
- println(balance)
+ println(statement)
Schritt 6: Erstellen Sie die Typklasse Liftable
Auch hier erstellen wir eine neue Typklasse. Nehmen wir an, wir haben einen weiteren Datenstrom mit dem Namen moneyInPocket und möchten dieses Geld zu unserer Statement Berechnung hinzufügen.
Für Scala 3:
trait Liftable[F[_]]:
def pure[A](a: A): F[A]
// code abreviated
...
extension [A](self: A)
def pure[F[_]](using sv: Liftable[F]): F[A] = sv.pure(self)
Für Scala 2 hätten wir dann:
implicit class LiftableSyntax[A](self: A) {
def pure[F[_]](implicit ev: Liftable[F]): F[A] = ev.pure(self)
}
Der Unterschied in der Art und Weise, wie man eine Erweiterungsmethode ausdrückt, ist in Scala 3 deutlicher als in Scala 2. Wenn man den Code von Scala 2 liest, muss man intuitiv davon ausgehen, dass der Programmierer es als Erweiterungsmethode gemeint hat, als er implicit class schrieb. Aber man könnte class leicht mit var oder def verwechseln, vor allem als Neueinsteiger. Scala 3 korrigiert dies durch die Benennung von extension, so dass Sie keinen Fehler machen, wenn Sie versuchen, sie zu verwenden.
Wenn wir unser Program.scala aktualisieren, haben wir nun die Möglichkeit, jeden beliebigen Wert in unser Programm zu übernehmen:
def getBalanceBank1: Maybe[Account] = Yes(Account("a1", 100))
def getBalanceBank2: Maybe[Int] = Yes(80)
+ val moneyInPocket: Int = 20
val b1: Maybe[Int] = getBalanceBank1.map(_.balance)
val b2: Maybe[Int] = getBalanceBank2
val balance: Maybe[Int] = b1 + b2
...
+ val p: Maybe[Int] = moneyInPocket.pure
+ val balance: Maybe[Int] = b1 + b2 + p
Weiter geht's.
Schritt 7: Erstellen einer Typklasse Flattener
Falls Sie es noch nicht bemerkt haben, sind wir fast fertig mit allen Monaden-Tutorials, die es gibt. Lassen Sie uns diese mystische Flatterner Eigenschaft erstellen:
trait Flattener[F[_]]:
def flatMap[A, B](fa: F[A], f: A => F[B]): F[B]
Warum die Typsignatur der Methode def flatMap so aussieht, werden wir so hinnehmen, wie sie ist. Aber wir haben jetzt die Möglichkeit, effektiv auf den enthaltenen Wert zuzugreifen, unabhängig davon, was das Wort effect bedeutet. Es ist erwähnenswert, dass es auch eine Intuition der Sequenzialität gibt, die durch diese Eigenschaft gegeben ist.
Für unser Arbeitsbeispiel können wir Program.scala auf diese Weise aktualisieren:
case class Account(id: String, balance: Int)
case class Statement(isRich: Boolean, accounts: Int)
- def getBalanceBank1: Maybe[Account] = Yes(Account("a1", 100))
+ def getBank1Credentials: Maybe[String] = Yes("MyUser_MyPassword")
+ def getBalanceBank1(credentials: String): Maybe[Account] = Yes(Account("a1", 100))
def getBalanceBank2: Maybe[Int] = Yes(80)
val moneyInPocket: Int = 20
- val b1: Maybe[Int] = getBalanceBank1.map(_.balance)
+ val b1: Maybe[Int] = getBank1Credentials.flatMap(cred => getBalanceBank1(cred).map(_.balance))
val b2: Maybe[Int] = getBalanceBank2
val p: Maybe[Int] = moneyInPocket.pure
val balance: Maybe[Int] = b1 + b2 + p
Schritt 8: Monadische Struktur verwenden
Ja, das Clickbait des M-Worts. Das bedeutet, dass wir jetzt for-comprehensions verwenden und unsere Datenstrukturen sequentiell zusammenstellen können. In unserem Program.scala würde das so aussehen:
- val b1: Maybe[Int] = getBank1Credentials.flatMap(cred => getBalanceBank1(cred).map(_.balance))
- val b2: Maybe[Int] = getBalanceBank2
- val p: Maybe[Int] = moneyInPocket.pure
- val balance: Maybe[Int] = b1 + b2 + p
+ val balance =
+ for
+ c <- getBank1Credentials
+ b1 <- getBalanceBank1(c).map(_.balance)
+ b2 <- getBalanceBank2
+ p <- moneyInPocket.pure
+ yield b1 + b2 + p
Die Verbesserungen können fortgesetzt werden, da wir weiterhin Typklassen einführen können, um Traits in unseren Programmen zu ergänzen und zu beschreiben. Wir belassen es vorerst dabei, aber wenn Sie mehr Details benötigen, lesen Sie bitte im Repository nach. Lesen Sie mehr über Typklassen, noch mehr überTypklassenBeratung und machen Sie einen Rundgang durch die funktionalen Typklassen.
Simulieren von Scala 3 Contextual Abstractions-Konzepten mit impliziten Scala 2-Konzepten
Gegebene Instanzen:
In Scala 3,
given intOrd: Ord[Int] with { ... }
bildet in Scala 2 zu
implicit object intOrd extends Ord[Int] { ... }
Erweiterungsmethoden
In Scala 3,
extension (c: Circle)
def circumference: Double = c.radius * math.Pi * 2
bildet in Scala 2 zu
implicit class CircleDecorator(c: Circle) extends AnyVal {
def circumference: Double = c.radius * math.Pi * 2
}
Parametrisierte Gegebenheiten
In Scala 3 haben wir es wie folgt beschrieben,
given listOrd[T](using ord: Ord[T]): Ord[List[T]] with { ... }
In Scala 2 hingegen müssen wir etwas Gymnastik betreiben:
class listOrd[T](implicit ord: Ord[T]) extends Ord[List[T]] { ... }
final implicit def listOrd[T](implicit ord: Ord[T]): listOrd[T] =
new listOrd[T]
Dies ist keine erschöpfende Auflistung. Sehen Sie sich daher unbedingt die offizielle Dokumentation an.
Fazit
Die kontextuellen Abstraktionen sind eine der leistungsfähigsten Funktionen von Scala. Nur wenige Sprachen haben sie, und ihre Überarbeitung macht sie attraktiver und leichter zu erlernen. Wir haben die Definition der kontextuellen Abstraktionen, ihre Kritik an der Scala 2-Implementierung, die Einführung von vier grundlegenden Änderungen, die die Sprachorthogonalität von Scala 3 verbessern, und Codebeispiele für Typklassen besprochen. Ich denke, man kann mit Sicherheit sagen, dass die kontextuelle Abstraktion in Scala 3 im Vergleich zu Scala 2 leichter zu verdauen ist. Außerdem bietet es tiefere Einblicke bei der Fehlersuche in Ihrem Scala 2 Implicits Compiler Problem.
Verfasst von
Esteban Marin
Scala Developer. Functional programming enthusiast.
Unsere Ideen
Weitere Blogs
Contact



