Überblick
Die Programmierung auf Typebene hat viele Ähnlichkeiten mit der herkömmlichen Programmierung auf Wertebene. Im Gegensatz zur Programmierung auf Wertebene, bei der die Berechnung zur Laufzeit erfolgt, erfolgt die Berechnung bei der Programmierung auf Typebene jedoch zur Kompilierungszeit. Ich werde versuchen, Parallelen zwischen der Programmierung auf Wertebene und der Programmierung auf Typebene zu ziehen.
Paradigmen
Bei der Programmierung auf Typebene gibt es zwei Hauptparadigmen: "objektorientiert" und "funktional". Die meisten von hier verlinkten Beispiele folgen dem objektorientierten Paradigma.
Ein gutes, ziemlich einfaches Beispiel für die Programmierung auf Typebene im objektorientierten Paradigma findet sich in der hier replizierten Apocalisp- Implementierung des Lambda-Kalküls :
// Abstract trait
trait Lambda {
type subst[U <: Lambda] <: Lambda
type apply[U <: Lambda] <: Lambda
type eval <: Lambda
}
// Implementations
trait App[S <: Lambda, T <: Lambda] extends Lambda {
type subst[U <: Lambda] = App[S#subst[U], T#subst[U]]
type apply[U] = Nothing
type eval = S#eval#apply[T]
}
trait Lam[T <: Lambda] extends Lambda {
type subst[U <: Lambda] = Lam[T]
type apply[U <: Lambda] = T#subst[U]#eval
type eval = Lam[T]
}
trait X extends Lambda {
type subst[U <: Lambda] = U
type apply[U] = Lambda
type eval = X
}
Wie im Beispiel zu sehen ist, läuft das objektorientierte Paradigma für die Programmierung auf Typebene wie folgt ab:
- Erstens: Definieren Sie ein abstraktes Merkmal mit verschiedenen abstrakten Typfeldern (siehe unten, was ein abstraktes Feld ist). Dies ist eine Vorlage, um sicherzustellen, dass bestimmte Typenfelder in allen Implementierungen vorhanden sind, ohne eine Implementierung zu erzwingen. In dem Lambda - Kalkül Beispiel das entspricht ,
trait Lambda
die garantiert , dass die folgenden Arten vorhanden ist : subst
, apply
und eval
.
- Weiter: Definieren Sie Untermerkmale, die das abstrakte Merkmal erweitern, und implementieren Sie die verschiedenen Felder für abstrakte Typen
- Oft werden diese Subtraits mit Argumenten parametrisiert. Im Beispiel der Lambda-Berechnung sind die Untertypen,
trait App extends Lambda
die mit zwei Typen parametrisiert sind ( S
und T
beide müssen Untertypen von sein Lambda
), trait Lam extends Lambda
mit einem Typ parametrisiert sind ( T
) und trait X extends Lambda
(der nicht parametrisiert ist).
- Die Typfelder werden häufig implementiert, indem auf die Typparameter des Subtraits verwiesen wird und manchmal auf ihre Typfelder über den Hash-Operator verwiesen wird:
#
(der dem Punktoperator sehr ähnlich ist: .
für Werte). Im Merkmal App
des Lambda-Kalkül-Beispiels wird der Typ eval
wie folgt implementiert : type eval = S#eval#apply[T]
. Dies bedeutet im Wesentlichen, den eval
Typ des Parameters des Merkmals S
aufzurufen und das Ergebnis apply
mit dem Parameter aufzurufen T
. Beachten Sie, dass S
garantiert ein eval
Typ vorhanden ist, da der Parameter angibt, dass es sich um einen Untertyp handelt Lambda
. In ähnlicher Weise muss das Ergebnis von eval
einen apply
Typ haben, da es als Subtyp von angegeben wird Lambda
, wie im abstrakten Merkmal angegeben Lambda
.
Das funktionale Paradigma besteht darin, viele parametrisierte Typkonstruktoren zu definieren, die nicht in Merkmalen zusammengefasst sind.
Vergleich zwischen Programmierung auf Wertebene und Programmierung auf Typebene
- abstrakte Klasse
- Wertebene:
abstract class C { val x }
- Typ-Ebene:
trait C { type X }
- Pfadabhängige Typen
C.x
(Verweis auf Feldwert / Funktion x in Objekt C)
C#x
(Referenzieren des Feldtyps x in Merkmal C)
- Funktionssignatur (keine Implementierung)
- Wertebene:
def f(x:X) : Y
- Typ-Ebene:
type f[x <: X] <: Y
(Dies wird als "Typ-Konstruktor" bezeichnet und tritt normalerweise im abstrakten Merkmal auf.)
- Funktionsimplementierung
- Wertebene:
def f(x:X) : Y = x
- Typ-Ebene:
type f[x <: X] = x
- Bedingungen
- Gleichstellung prüfen
- Wertebene:
a:A == b:B
- Typ-Ebene:
implicitly[A =:= B]
- Wertebene: Passiert in der JVM über einen Unit-Test zur Laufzeit (dh keine Laufzeitfehler):
- in Essenz ist eine Behauptung:
assert(a == b)
- Typenebene: Passiert im Compiler über einen Typcheck (dh keine Compilerfehler):
- Im Wesentlichen handelt es sich um einen Typvergleich: z
implicitly[A =:= B]
A <:< B
, wird nur kompiliert, wenn A
es sich um einen Subtyp von handeltB
A =:= B
, wird nur kompiliert, wenn A
es sich um einen Subtyp von B
und B
um einen Subtyp von handeltA
A <%< B
, ("sichtbar als") wird nur kompiliert, wenn A
sichtbar als B
(dh es gibt eine implizite Konvertierung von A
in einen Subtyp von B
)
- ein Beispiel
- mehr Vergleichsoperatoren
Konvertieren zwischen Typen und Werten
In vielen Beispielen sind Typen, die über Merkmale definiert werden, häufig sowohl abstrakt als auch versiegelt und können daher weder direkt noch über eine anonyme Unterklasse instanziiert werden. Daher ist es üblich, null
einen Platzhalterwert zu verwenden, wenn eine Berechnung auf Wertebene mit einem bestimmten Interesse durchgeführt wird:
- zB
val x:A = null
, wo A
ist der Typ, den Sie interessieren
Aufgrund der Typlöschung sehen parametrisierte Typen alle gleich aus. Darüber hinaus sind (wie oben erwähnt) die Werte, mit denen Sie arbeiten, in der Regel alle null
, sodass eine Konditionierung des Objekttyps (z. B. über eine Übereinstimmungsanweisung) unwirksam ist.
Der Trick besteht darin, implizite Funktionen und Werte zu verwenden. Der Basisfall ist normalerweise ein impliziter Wert und der rekursive Fall ist normalerweise eine implizite Funktion. In der Tat werden bei der Programmierung auf Typebene Implikationen stark genutzt.
Betrachten Sie dieses Beispiel ( entnommen aus metascala und apocalisp ):
sealed trait Nat
sealed trait _0 extends Nat
sealed trait Succ[N <: Nat] extends Nat
Hier haben Sie eine Peano-Codierung der natürlichen Zahlen. Das heißt, Sie haben einen Typ für jede nicht negative Ganzzahl: einen speziellen Typ für 0, nämlich _0
; und jede ganze Zahl größer als Null hat einen Typ der Form Succ[A]
, wobei A
der Typ eine kleinere ganze Zahl darstellt. Der Typ, der 2 darstellt, wäre beispielsweise: Succ[Succ[_0]]
(Nachfolger wird zweimal auf den Typ angewendet, der Null darstellt).
Wir können verschiedene natürliche Zahlen für eine bequemere Referenz aliasisieren. Beispiel:
type _3 = Succ[Succ[Succ[_0]]]
(Dies ähnelt der Definition von a val
als Ergebnis einer Funktion.)
Nehmen wir nun an, wir möchten eine Funktion auf Wertebene definieren, def toInt[T <: Nat](v : T)
die einen Argumentwert annimmt, der einer Ganzzahl v
entspricht Nat
und diese zurückgibt, die die natürliche Zahl darstellt, die in v
's Typ codiert ist . Wenn wir beispielsweise den Wert val x:_3 = null
( null
vom Typ Succ[Succ[Succ[_0]]]
) haben, möchten wir toInt(x)
zurückkehren 3
.
Zur Implementierung verwenden toInt
wir die folgende Klasse:
class TypeToValue[T, VT](value : VT) { def getValue() = value }
Wie wir unten sehen werden, wird es TypeToValue
für jede Nat
von _0
bis zu (z._3
, und jedes wird die Wertdarstellung des entsprechenden Typs TypeToValue[_0, Int]
speichern (dh wird den Wert speichern 0
, TypeToValue[Succ[_0], Int]
wird den Wert speichern 1
usw.). Hinweis: TypeToValue
wird durch zwei Typen parametrisiert: T
und VT
. T
entspricht dem Typ, dem wir Werte zuweisen möchten (in unserem Beispiel Nat
), und VT
entspricht dem Werttyp, den wir ihm zuweisen (in unserem Beispiel Int
).
Nun machen wir die folgenden zwei impliziten Definitionen:
implicit val _0ToInt = new TypeToValue[_0, Int](0)
implicit def succToInt[P <: Nat](implicit v : TypeToValue[P, Int]) =
new TypeToValue[Succ[P], Int](1 + v.getValue())
Und wir implementieren toInt
wie folgt:
def toInt[T <: Nat](v : T)(implicit ttv : TypeToValue[T, Int]) : Int = ttv.getValue()
Um zu verstehen, wie es toInt
funktioniert, betrachten wir einige Eingaben:
val z:_0 = null
val y:Succ[_0] = null
Wenn wir anrufen toInt(z)
, sucht der Compiler nach einem impliziten Argument ttv
vom Typ TypeToValue[_0, Int]
(da z
es vom Typ ist _0
). Es findet das Objekt _0ToInt
, ruft die getValue
Methode dieses Objekts auf und kehrt zurück 0
. Der wichtige Punkt ist, dass wir dem Programm nicht angegeben haben, welches Objekt verwendet werden soll. Der Compiler hat es implizit gefunden.
Nun wollen wir überlegen toInt(y)
. Diesmal sucht der Compiler nach einem impliziten Argument ttv
vom Typ TypeToValue[Succ[_0], Int]
(da y
es vom Typ ist Succ[_0]
). Es findet die Funktion succToInt
, die ein Objekt des entsprechenden Typs ( TypeToValue[Succ[_0], Int]
) zurückgeben und auswerten kann. Diese Funktion selbst verwendet ein implizites Argument ( v
) vom Typ TypeToValue[_0, Int]
(d. H. A.TypeToValue
wenn der erste Typparameter eins weniger hat Succ[_]
). Der Compiler liefert _0ToInt
(wie in der toInt(z)
obigen Auswertung ) und erstellt succToInt
ein neues TypeToValue
Objekt mit Wert 1
. Auch hier ist zu beachten, dass der Compiler alle diese Werte implizit bereitstellt, da wir keinen expliziten Zugriff darauf haben.
Überprüfen Sie Ihre Arbeit
Es gibt verschiedene Möglichkeiten, um zu überprüfen, ob Ihre Berechnungen auf Typebene das tun, was Sie erwarten. Hier sind einige Ansätze. Machen Sie zwei ArtenA
und B
, die Sie überprüfen möchten, sind gleich. Überprüfen Sie dann Folgendes:
Equal[A, B]
implicitly[A =:= B]
Alternativ können Sie den Typ in einen Wert konvertieren (wie oben gezeigt) und eine Laufzeitprüfung der Werte durchführen. ZB assert(toInt(a) == toInt(b))
wo a
ist vom Typ A
und b
ist vom Typ B
.
Zusätzliche Ressourcen
Den vollständigen Satz verfügbarer Konstrukte finden Sie im Abschnitt Typen des Scala-Referenzhandbuchs (pdf) .
Adriaan Moors hat mehrere wissenschaftliche Artikel über Typkonstruktoren und verwandte Themen mit Beispielen aus Scala:
Apocalisp ist ein Blog mit vielen Beispielen für die Programmierung auf Typebene in Scala.
ScalaZ ist ein sehr aktives Projekt, das Funktionen bereitstellt, die die Scala-API mithilfe verschiedener Programmierfunktionen auf erweitern. Es ist ein sehr interessantes Projekt, das eine große Anhängerschaft hat.
MetaScala ist eine Bibliothek auf Typebene für Scala, einschließlich Metatypen für natürliche Zahlen, Boolesche Werte, Einheiten, HList usw. Es ist ein Projekt von Jesper Nordenberg (sein Blog) .
Der Michid (Blog) hat einige großartige Beispiele für die Programmierung auf Typebene in Scala (aus anderen Antworten):
Debasish Ghosh (Blog) hat auch einige relevante Beiträge:
(Ich habe einige Nachforschungen zu diesem Thema angestellt und hier ist, was ich gelernt habe. Ich bin noch neu darin. Bitte weisen Sie auf Ungenauigkeiten in dieser Antwort hin.)