Implizite Konvertierung vs. Typklasse

93

In Scala können wir mindestens zwei Methoden verwenden, um vorhandene oder neue Typen nachzurüsten. Angenommen, wir möchten ausdrücken, dass etwas mit einem quantifiziert werden kann Int. Wir können das folgende Merkmal definieren.

Implizite Konvertierung

trait Quantifiable{ def quantify: Int }

Und dann können wir implizite Konvertierungen verwenden, um z. B. Zeichenfolgen und Listen zu quantifizieren.

implicit def string2quant(s: String) = new Quantifiable{ 
  def quantify = s.size 
}
implicit def list2quantifiable[A](l: List[A]) = new Quantifiable{ 
  val quantify = l.size 
}

Nachdem wir diese importiert haben, können wir die Methode quantifyfür Zeichenfolgen und Listen aufrufen . Beachten Sie, dass die quantifizierbare Liste ihre Länge speichert, sodass das teure Durchlaufen der Liste bei nachfolgenden Aufrufen von vermieden wird quantify.

Typklassen

Eine Alternative besteht darin, einen "Zeugen" zu definieren Quantified[A], der besagt, dass ein Typ Aquantifiziert werden kann.

trait Quantified[A] { def quantify(a: A): Int }

Wir stellen dann Instanzen dieser Typklasse für Stringund Listirgendwo zur Verfügung.

implicit val stringQuantifiable = new Quantified[String] {
  def quantify(s: String) = s.size 
}

Und wenn wir dann eine Methode schreiben, die ihre Argumente quantifizieren muss, schreiben wir:

def sumQuantities[A](as: List[A])(implicit ev: Quantified[A]) = 
  as.map(ev.quantify).sum

Oder verwenden Sie die kontextgebundene Syntax:

def sumQuantities[A: Quantified](as: List[A]) = 
  as.map(implicitly[Quantified[A]].quantify).sum

Aber wann welche Methode anwenden?

Nun kommt die Frage. Wie kann ich mich zwischen diesen beiden Konzepten entscheiden?

Was mir bisher aufgefallen ist.

Typklassen

  • Typklassen ermöglichen die nette kontextgebundene Syntax
  • Mit Typklassen erstelle ich nicht bei jeder Verwendung ein neues Wrapper-Objekt
  • Die kontextgebundene Syntax funktioniert nicht mehr, wenn die Typklasse mehrere Typparameter hat. Stellen Sie sich vor, ich möchte Dinge nicht nur mit ganzen Zahlen, sondern auch mit Werten eines allgemeinen Typs quantifizieren T. Ich möchte eine Typklasse erstellenQuantified[A,T]

implizite Konvertierung

  • Da ich ein neues Objekt erstelle, kann ich dort Werte zwischenspeichern oder eine bessere Darstellung berechnen. Aber sollte ich das vermeiden, da es mehrmals vorkommen kann und eine explizite Konvertierung wahrscheinlich nur einmal aufgerufen wird?

Was ich von einer Antwort erwarte

Stellen Sie einen (oder mehrere) Anwendungsfälle vor, bei denen der Unterschied zwischen beiden Konzepten von Bedeutung ist, und erklären Sie, warum ich einen dem anderen vorziehen würde. Es wäre auch ohne Beispiel schön, die Essenz der beiden Konzepte und ihre Beziehung zueinander zu erklären.

Zikzystar
quelle
Es gibt einige Verwirrung in den Typklassenpunkten, in denen Sie "Ansicht gebunden" erwähnen, obwohl Typklassen Kontextgrenzen verwenden.
Daniel C. Sobral
1
+1 ausgezeichnete Frage; Ich bin sehr an einer gründlichen Antwort darauf interessiert.
Dan Burton
@ Daniel Danke. Ich verstehe die immer falsch.
Ziggystar
2
Sie irren sich an einer Stelle: In Ihrem zweiten impliziten Konvertierungsbeispiel speichern Sie das sizeeiner Liste in einem Wert und sagen, dass es das teure Durchlaufen der Liste bei nachfolgenden zu quantifizierenden Aufrufen vermeidet, aber bei jedem Aufruf an quantifydie list2quantifiablewird ausgelöst alles noch einmal, wodurch Quantifiabledas quantifyEigentum wieder hergestellt und neu berechnet wird . Ich sage, dass es tatsächlich keine Möglichkeit gibt, die Ergebnisse mit impliziten Konvertierungen zwischenzuspeichern.
Nikita Volkov
@ NikitaVolkov Deine Beobachtung ist richtig. Und das spreche ich in meiner Frage im vorletzten Absatz an. Das Caching funktioniert, wenn das konvertierte Objekt nach einem Aufruf der Konvertierungsmethode länger verwendet wird (und möglicherweise in seiner konvertierten Form weitergegeben wird). Während Typklassen wahrscheinlich entlang des nicht konvertierten Objekts verkettet würden, wenn sie tiefer gehen.
Ziggystar

Antworten:

42

Ich möchte mein Material nicht von Scala In Depth duplizieren , aber ich denke, es ist erwähnenswert, dass Typklassen / Typmerkmale unendlich flexibler sind.

def foo[T: TypeClass](t: T) = ...

hat die Möglichkeit, seine lokale Umgebung nach einer Standardtypklasse zu durchsuchen. Ich kann das Standardverhalten jedoch jederzeit auf zwei Arten überschreiben:

  1. Erstellen / Importieren einer impliziten Typklasseninstanz in Scope, um die implizite Suche kurzzuschließen
  2. Direktes Übergeben einer Typklasse

Hier ist ein Beispiel:

def myMethod(): Unit = {
   // overrides default implicit for Int
   implicit object MyIntFoo extends Foo[Int] { ... }
   foo(5)
   foo(6) // These all use my overridden type class
   foo(7)(new Foo[Int] { ... }) // This one needs a different configuration
}

Dies macht Typklassen unendlich flexibler. Eine andere Sache ist, dass Typklassen / Merkmale die implizite Suche besser unterstützen.

Wenn Sie in Ihrem ersten Beispiel eine implizite Ansicht verwenden, führt der Compiler eine implizite Suche durch nach:

Function1[Int, ?]

Welches wird auf Function1das Begleitobjekt und das IntBegleitobjekt schauen .

Beachten Sie, dass Quantifiablesich die implizite Suche nirgends befindet . Dies bedeutet, dass Sie die implizite Ansicht in ein Paketobjekt einfügen oder in den Bereich importieren müssen. Es ist mehr Arbeit, sich daran zu erinnern, was los ist.

Andererseits ist eine Typklasse explizit . Sie sehen, wonach es sucht, in der Methodensignatur. Sie haben auch eine implizite Suche nach

Quantifiable[Int]

welches in Quantifiabledas Begleitobjekt und Int das Begleitobjekt schauen wird . Dies bedeutet, dass Sie Standardeinstellungen angeben können und neue Typen (wie eine MyStringKlasse) einen Standard in ihrem Begleitobjekt angeben können, der implizit durchsucht wird.

Im Allgemeinen verwende ich Typklassen. Sie sind für das erste Beispiel unendlich flexibler. Der einzige Ort, an dem ich implizite Konvertierungen verwende, ist die Verwendung einer API-Ebene zwischen einem Scala-Wrapper und einer Java-Bibliothek. Selbst dies kann "gefährlich" sein, wenn Sie nicht vorsichtig sind.

jsuereth
quelle
20

Ein Kriterium, das ins Spiel kommen kann, ist, wie sich die neue Funktion "anfühlen" soll. Mit impliziten Konvertierungen können Sie es so aussehen lassen, als wäre es nur eine andere Methode:

"my string".newFeature

... bei der Verwendung von Typklassen sieht es immer so aus, als würden Sie eine externe Funktion aufrufen:

newFeature("my string")

Eine Sache, die Sie mit Typklassen und nicht mit impliziten Konvertierungen erreichen können, ist das Hinzufügen von Eigenschaften zu einem Typ und nicht zu einer Instanz eines Typs. Sie können dann auf diese Eigenschaften zugreifen, auch wenn keine Instanz des Typs verfügbar ist. Ein kanonisches Beispiel wäre:

trait Default[T] { def value : T }

implicit object DefaultInt extends Default[Int] {
  def value = 42
}

implicit def listsHaveDefault[T : Default] = new Default[List[T]] {
  def value = implicitly[Default[T]].value :: Nil
}

def default[T : Default] = implicitly[Default[T]].value

scala> default[List[List[Int]]]
resN: List[List[Int]] = List(List(42))

Dieses Beispiel zeigt auch, wie eng die Konzepte miteinander verbunden sind: Typklassen wären bei weitem nicht so nützlich, wenn es keinen Mechanismus gäbe, um unendlich viele ihrer Instanzen zu erzeugen. Ohne die implicitMethode (zugegebenermaßen keine Konvertierung) hätte ich nur endlich viele Typen die DefaultEigenschaft haben können.

Philippe
quelle
@Phillippe - Ich bin sehr interessiert an der Technik, die Sie geschrieben haben ... aber es scheint nicht auf Scala 2.11.6 zu funktionieren. Ich habe eine Frage gestellt und um ein Update Ihrer Antwort gebeten. Vielen
Chris Bedford
@ ChrisBedford Ich habe die Definition defaultfür zukünftige Leser hinzugefügt .
Philippe
13

Sie können sich den Unterschied zwischen den beiden Techniken analog zur Funktionsanwendung vorstellen, nur mit einem benannten Wrapper. Beispielsweise:

trait Foo1[A] { def foo(a: A): Int }  // analogous to A => Int
trait Foo0    { def foo: Int }        // analogous to Int

Eine Instanz der ersteren kapselt eine Funktion vom Typ A => Int, während eine Instanz der letzteren bereits auf eine angewendet wurde A. Sie könnten das Muster fortsetzen ...

trait Foo2[A, B] { def foo(a: A, b: B): Int } // sort of like A => B => Int

man könnte sich also so Foo1[B]etwas wie die teilweise Anwendung Foo2[A, B]auf eine AInstanz vorstellen. Ein gutes Beispiel dafür wurde von Miles Sabin als "Functional Dependencies in Scala" geschrieben .

Mein Punkt ist also wirklich, dass im Prinzip:

  • Das "Pimpen" einer Klasse (durch implizite Konvertierung) ist der Fall "nullter Ordnung" ...
  • Das Deklarieren einer Typklasse ist der Fall "erster Ordnung" ...
  • Multi-Parameter-Typklassen mit Fundeps (oder so etwas wie Fundeps) sind der allgemeine Fall.
Zusammenführungskonflikt
quelle