Sauberere Möglichkeit, verschachtelte Strukturen zu aktualisieren


124

Angenommen, ich habe zwei folgende case class:

case class Address(street: String, city: String, state: String, zipCode: Int)
case class Person(firstName: String, lastName: String, address: Address)

und die folgende Instanz der PersonKlasse:

val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg", 
                                           "Mumbai", 
                                           "Maharashtra", 
                                           411342))

Nun , wenn ich zu aktualisierenden zipCodevon rajdann werde ich zu tun haben:

val updatedRaj = raj.copy(address = raj.address.copy(zipCode = raj.address.zipCode + 1))

Mit mehr Verschachtelungsebenen wird dies noch hässlicher. Gibt es eine sauberere Möglichkeit (so etwas wie die von Clojure update-in), solche verschachtelten Strukturen zu aktualisieren?


1
Ich gehe davon aus, dass Sie die Unveränderlichkeit bewahren möchten, andernfalls kleben Sie einfach eine Variable vor die Adresserklärung der Personen.
GClaramunt

8
@ GClaramunt: Ja, ich möchte die Unveränderlichkeit bewahren.
fehlender Faktor

Antworten:


94

Reißverschlüsse

Huets Reißverschluss bietet eine bequeme Durchquerung und "Mutation" einer unveränderlichen Datenstruktur. Scalaz bietet Reißverschlüsse für Stream( scalaz.Zipper ) und Tree( scalaz.TreeLoc ). Es stellt sich heraus, dass die Struktur des Reißverschlusses automatisch von der ursprünglichen Datenstruktur abgeleitet werden kann, und zwar auf eine Weise, die der symbolischen Differenzierung eines algebraischen Ausdrucks ähnelt.

Aber wie hilft Ihnen das bei Ihren Scala-Fallklassen? Nun, Lukas Rytz hat kürzlich einen Prototyp für eine Erweiterung von Scalac entwickelt, mit der automatisch Reißverschlüsse für kommentierte Fallklassen erstellt werden. Ich werde sein Beispiel hier wiedergeben:

scala> @zip case class Pacman(lives: Int = 3, superMode: Boolean = false) 
scala> @zip case class Game(state: String = "pause", pacman: Pacman = Pacman()) 
scala> val g = Game() 
g: Game = Game("pause",Pacman(3,false))

// Changing the game state to "run" is simple using the copy method:
scala> val g1 = g.copy(state = "run") 
g1: Game = Game("run",Pacman(3,false))

// However, changing pacman's super mode is much more cumbersome (and it gets worse for deeper structures):
scala> val g2 = g1.copy(pacman = g1.pacman.copy(superMode = true))
g2: Game = Game("run",Pacman(3,true))

// Using the compiler-generated location classes this gets much easier: 
scala> val g3 = g1.loc.pacman.superMode set true
g3: Game = Game("run",Pacman(3,true)

Daher muss die Community das Scala-Team davon überzeugen, dass diese Bemühungen fortgesetzt und in den Compiler integriert werden sollten.

Übrigens Lukas vor kurzem veröffentlichte eine Version von Pacman, benutzerprogrammierbare über einen DSL. Sieht aber nicht so aus, als hätte er den modifizierten Compiler verwendet, da ich keine @zipAnmerkungen sehen kann .

Umschreiben von Bäumen

Unter anderen Umständen möchten Sie möglicherweise eine Transformation auf die gesamte Datenstruktur anwenden, die einer bestimmten Strategie (von oben nach unten, von unten nach oben) entspricht und auf Regeln basiert, die an einem bestimmten Punkt in der Struktur mit dem Wert übereinstimmen. Das klassische Beispiel ist die Transformation eines AST für eine Sprache, um möglicherweise Informationen auszuwerten, zu vereinfachen oder zu sammeln. Kiama unterstützt das Umschreiben , sehen Sie sich die Beispiele in RewriterTests an und sehen Sie sich dieses Video an . Hier ist ein Ausschnitt, der Appetit macht:

// Test expression
val e = Mul (Num (1), Add (Sub (Var ("hello"), Num (2)), Var ("harold")))

// Increment every double
val incint = everywheretd (rule { case d : Double => d + 1 })
val r1 = Mul (Num (2), Add (Sub (Var ("hello"), Num (3)), Var ("harold")))
expect (r1) (rewrite (incint) (e))

Beachten Sie, dass Kiama außerhalb Schritte vom Typ System dies zu erreichen.


2
Für diejenigen, die das Commit suchen. Hier ist es: github.com/soundrabbit/scala/commit/… (ich denke ..)
IttayD

15
Hey, wo sind die Linsen?
Daniel C. Sobral

Ich bin gerade auf dieses Problem gestoßen und die @ zIP-Idee klingt wirklich fantastisch. Vielleicht sollte sie sogar so weit gehen, dass alle Fallklassen sie haben? Warum ist das nicht implementiert? Objektive sind schön, aber mit großen und vielen Klassen / Fallklassen ist es nur ein Boilerplate, wenn Sie nur einen Setter wollen und nichts Besonderes wie ein Inkrementierer.
Johan S

186

Komisch, dass niemand Objektive hinzufügte, da sie für solche Sachen gemacht wurden. Also, hier ist ein CS-Hintergrundpapier dazu, hier ist ein Blog, der kurz auf die Verwendung von Objektiven in Scala eingeht , hier ist eine Objektivimplementierung für Scalaz und hier ist ein Code, der es verwendet, was überraschend wie Ihre Frage aussieht. Und um die Kesselplatte zu reduzieren, hier ein Plugin, das Scalaz-Objektive für Fallklassen generiert.

Für Bonuspunkte gibt es hier eine weitere SO-Frage, die Linsen berührt, und ein Papier von Tony Morris.

Die große Sache bei Objektiven ist, dass sie zusammensetzbar sind. Sie sind anfangs etwas umständlich, aber sie gewinnen immer mehr an Boden, je mehr Sie sie verwenden. Sie eignen sich auch hervorragend für die Testbarkeit, da Sie nur einzelne Objektive testen müssen und deren Zusammensetzung als selbstverständlich vorausgesetzt werden kann.

Basierend auf einer Implementierung am Ende dieser Antwort erfahren Sie hier, wie Sie dies mit Objektiven tun würden. Deklarieren Sie zunächst Objektive, um eine Postleitzahl in einer Adresse und eine Adresse in einer Person zu ändern:

val addressZipCodeLens = Lens(
    get = (_: Address).zipCode,
    set = (addr: Address, zipCode: Int) => addr.copy(zipCode = zipCode))

val personAddressLens = Lens(
    get = (_: Person).address, 
    set = (p: Person, addr: Address) => p.copy(address = addr))

Stellen Sie sie nun zusammen, um ein Objektiv zu erhalten, das die Postleitzahl einer Person ändert:

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

Verwenden Sie zum Schluss dieses Objektiv, um Raj zu wechseln:

val updatedRaj = personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)

Oder mit syntaktischem Zucker:

val updatedRaj = personZipCodeLens.set(raj, personZipCodeLens(raj) + 1)

Oder auch:

val updatedRaj = personZipCodeLens.mod(raj, zip => zip + 1)

Hier ist die einfache Implementierung von Scalaz, die für dieses Beispiel verwendet wird:

case class Lens[A,B](get: A => B, set: (A,B) => A) extends Function1[A,B] with Immutable {
  def apply(whole: A): B   = get(whole)
  def updated(whole: A, part: B): A = set(whole, part) // like on immutable maps
  def mod(a: A, f: B => B) = set(a, f(this(a)))
  def compose[C](that: Lens[C,A]) = Lens[C,B](
    c => this(that(c)),
    (c, b) => that.mod(c, set(_, b))
  )
  def andThen[C](that: Lens[B,C]) = that compose this
}

1
Vielleicht möchten Sie diese Antwort mit einer Beschreibung des Objektiv-Plugins von Gerolf Seitz aktualisieren.
fehlender Faktor

@missingfaktor Sicher. Verknüpfung? Mir war ein solches Plugin nicht bekannt.
Daniel C. Sobral

1
Der Code personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)ist die gleiche wiepersonZipCodeLens mod (raj, _ + 1)
ron

@ron modist jedoch kein Grundelement für Objektive.
Daniel C. Sobral

Tony Morris hat eine großartige Arbeit zu diesem Thema geschrieben. Ich denke, Sie sollten es in Ihrer Antwort verknüpfen.
fehlender Faktor

11

Nützliche Werkzeuge zur Verwendung von Objektiven:

Ich möchte nur hinzufügen, dass die auf Scala 2.10-Makros basierenden Projekte Macrocosm und Rillit die Erstellung dynamischer Objektive ermöglichen.


Verwenden von Rillit:

case class Email(user: String, domain: String)
case class Contact(email: Email, web: String)
case class Person(name: String, contact: Contact)

val person = Person(
  name = "Aki Saarinen",
  contact = Contact(
    email = Email("aki", "akisaarinen.fi"),
    web   = "http://akisaarinen.fi"
  )
)

scala> Lenser[Person].contact.email.user.set(person, "john")
res1: Person = Person(Aki Saarinen,Contact(Email(john,akisaarinen.fi),http://akisaarinen.fi))

Verwenden von Macrocosm:

Dies funktioniert sogar für Fallklassen, die im aktuellen Kompilierungslauf definiert sind.

case class Person(name: String, age: Int)

val p = Person("brett", 21)

scala> lens[Person].name._1(p)
res1: String = brett

scala> lens[Person].name._2(p, "bill")
res2: Person = Person(bill,21)

scala> lens[Person].namexx(()) // Compilation error

Sie haben wahrscheinlich Rillit verpasst, was noch besser ist. :-) github.com/akisaarinen/rillit
fehlender Faktor

Schön, werde das überprüfen
Sebastien Lorber

1
Übrigens habe ich meine Antwort so bearbeitet, dass sie Rillit enthält, aber ich verstehe nicht wirklich, warum Rillit besser ist. Sie scheinen auf den ersten Blick dieselbe Funktionalität in derselben Ausführlichkeit zu bieten @missingfaktor
Sebastien Lorber

@SebastienLorber Lustige Tatsache: Rillit ist finnisch und bedeutet Objektive :)
Kai Sellgren

Sowohl Macrocosm als auch Rillit scheinen in den letzten 4 Jahren nicht aktualisiert worden zu sein.
Erik van Oosten

9

Ich habe mich nach einer Scala-Bibliothek umgesehen, die die schönste Syntax und die beste Funktionalität hat, und eine Bibliothek, die hier nicht erwähnt wird, ist Monokel, was für mich wirklich gut war. Ein Beispiel folgt:

import monocle.Macro._
import monocle.syntax._

case class A(s: String)
case class B(a: A)

val aLens = mkLens[B, A]("a")
val sLens = aLens |-> mkLens[A, String]("s")

//Usage
val b = B(A("hi"))
val newB = b |-> sLens set("goodbye") // gives B(A("goodbye"))

Diese sind sehr schön und es gibt viele Möglichkeiten, die Linsen zu kombinieren. Scalaz zum Beispiel erfordert viel Boilerplate und dies kompiliert schnell und läuft großartig.

Um sie in Ihrem Projekt zu verwenden, fügen Sie dies einfach Ihren Abhängigkeiten hinzu:

resolvers ++= Seq(
  "Sonatype OSS Releases"  at "http://oss.sonatype.org/content/repositories/releases/",
  "Sonatype OSS Snapshots" at "http://oss.sonatype.org/content/repositories/snapshots/"
)

val scalaVersion   = "2.11.0" // or "2.10.4"
val libraryVersion = "0.4.0"  // or "0.5-SNAPSHOT"

libraryDependencies ++= Seq(
  "com.github.julien-truffaut"  %%  "monocle-core"    % libraryVersion,
  "com.github.julien-truffaut"  %%  "monocle-generic" % libraryVersion,
  "com.github.julien-truffaut"  %%  "monocle-macro"   % libraryVersion,       // since 0.4.0
  "com.github.julien-truffaut"  %%  "monocle-law"     % libraryVersion % test // since 0.4.0
)

7

Formlos macht den Trick:

"com.chuusai" % "shapeless_2.11" % "2.0.0"

mit:

case class Address(street: String, city: String, state: String, zipCode: Int)
case class Person(firstName: String, lastName: String, address: Address)

object LensSpec {
      import shapeless._
      val zipLens = lens[Person] >> 'address >> 'zipCode  
      val surnameLens = lens[Person] >> 'firstName
      val surnameZipLens = surnameLens ~ zipLens
}

class LensSpec extends WordSpecLike with Matchers {
  import LensSpec._
  "Shapless Lens" should {
    "do the trick" in {

      // given some values to recreate
      val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg",
        "Mumbai",
        "Maharashtra",
        411342))
      val updatedRaj = raj.copy(address = raj.address.copy(zipCode = raj.address.zipCode + 1))

      // when we use a lens
      val lensUpdatedRaj = zipLens.set(raj)(raj.address.zipCode + 1)

      // then it matches the explicit copy
      assert(lensUpdatedRaj == updatedRaj)
    }

    "better yet chain them together as a template of values to set" in {

      // given some values to recreate
      val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg",
        "Mumbai",
        "Maharashtra",
        411342))

      val updatedRaj = raj.copy(firstName="Rajendra", address = raj.address.copy(zipCode = raj.address.zipCode + 1))

      // when we use a compound lens
      val lensUpdatedRaj = surnameZipLens.set(raj)("Rajendra", raj.address.zipCode+1)

      // then it matches the explicit copy
      assert(lensUpdatedRaj == updatedRaj)
    }
  }
}

Beachten Sie, dass Sie mit einigen anderen Antworten hier Objektive zusammenstellen können, um tiefer in eine bestimmte Struktur einzudringen. Mit diesen shapless-Objektiven (und anderen Bibliotheken / Makros) können Sie zwei nicht verwandte Objektive kombinieren, sodass Sie ein Objektiv erstellen können, das eine beliebige Anzahl von Parametern in beliebige Positionen setzt in Ihrer Struktur. Für komplexe Datenstrukturen ist diese zusätzliche Komposition sehr hilfreich.


Beachten Sie, dass ich schließlich den LensCode in Daniel C. Sobrals Antwort verwendet habe und so vermieden habe, eine externe Abhängigkeit hinzuzufügen.
Simbo1905

7

Linsen bieten aufgrund ihrer Zusammensetzbarkeit eine sehr gute Lösung für das Problem stark verschachtelter Strukturen. Bei einem geringen Verschachtelungsgrad habe ich jedoch manchmal das Gefühl, dass Objektive etwas zu viel sind, und ich möchte nicht den gesamten Objektivansatz einführen, wenn es nur wenige Stellen mit verschachtelten Aktualisierungen gibt. Der Vollständigkeit halber ist hier eine sehr einfache / pragmatische Lösung für diesen Fall:

Ich schreibe einfach ein paar modify...Hilfsfunktionen in die Struktur der obersten Ebene, die sich mit der hässlichen verschachtelten Kopie befassen. Zum Beispiel:

case class Person(firstName: String, lastName: String, address: Address) {
  def modifyZipCode(modifier: Int => Int) = 
    this.copy(address = address.copy(zipCode = modifier(address.zipCode)))
}

Mein Hauptziel (Vereinfachung des Updates auf Client-Seite) wird erreicht:

val updatedRaj = raj.modifyZipCode(_ => 41).modifyZipCode(_ + 1)

Das Erstellen des vollständigen Satzes von Änderungshilfen ist offensichtlich ärgerlich. Für interne Inhalte ist es jedoch häufig in Ordnung, sie nur zu erstellen, wenn Sie zum ersten Mal versuchen, ein bestimmtes verschachteltes Feld zu ändern.


4

Vielleicht passt QuickLens besser zu Ihrer Frage. QuickLens verwendet Makros, um einen IDE-freundlichen Ausdruck in etwas zu konvertieren, das der ursprünglichen Kopieranweisung nahe kommt.

Angesichts der beiden Beispielfallklassen:

case class Address(street: String, city: String, state: String, zipCode: Int)
case class Person(firstName: String, lastName: String, address: Address)

und die Instanz der Personenklasse:

val raj = Person("Raj", "Shekhar", Address("M Gandhi Marg", 
                                           "Mumbai", 
                                           "Maharashtra", 
                                           411342))

Sie können die Postleitzahl von Raj aktualisieren mit:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.