Czystszy sposób aktualizowania zagnieżdżonych struktur

124

Powiedzmy, że mam następujące dwa case classes:

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

i następujące wystąpienie Personklasy:

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

Teraz, jeśli chcę zaktualizować zipCodez rajczym będę miał do zrobienia:

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

Im więcej poziomów zagnieżdżenia, tym brzydszy. Czy istnieje czystszy sposób (podobny do Clojure update-in) na aktualizację takich zagnieżdżonych struktur?

missingfaktor
źródło
1
Zakładam, że chcesz zachować niezmienność, w przeciwnym razie po prostu umieść var ​​przed deklaracją adresową osób.
GClaramunt
8
@GClaramunt: Tak, chcę zachować niezmienność.
missingfaktor

Odpowiedzi:

94

Zamki błyskawiczne

Huet's Zipper zapewnia wygodne przechodzenie i „mutację” niezmiennej struktury danych. Scalaz dostarcza Zippers dla Stream( scalaz.Zipper ) i Tree( scalaz.TreeLoc ). Okazuje się, że struktura zamka błyskawicznego jest automatycznie wyprowadzana z pierwotnej struktury danych, w sposób przypominający symboliczne zróżnicowanie wyrażenia algebraicznego.

Ale w jaki sposób pomoże Ci to w Twoich klasach przypadków Scala? Cóż, Lukas Rytz niedawno opracował prototyp rozszerzenia do scalaca, które automatycznie tworzy suwaki dla klas przypadków z adnotacjami. Przedstawię tutaj jego przykład:

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)

Dlatego społeczność musi przekonać zespół Scala, że ​​ten wysiłek powinien być kontynuowany i zintegrowany z kompilatorem.

Nawiasem mówiąc, Lukas niedawno opublikował wersję Pacmana, programowalną przez użytkownika przez DSL. Wygląda jednak na to, że nie korzystał ze zmodyfikowanego kompilatora, ponieważ żadnego nie widzę@zip adnotacji.

Przepisywanie drzewa

W innych okolicznościach możesz chcieć zastosować pewną transformację w całej strukturze danych, zgodnie z pewną strategią (odgórną, oddolną) i na podstawie reguł dopasowanych do wartości w pewnym punkcie struktury. Klasycznym przykładem jest przekształcenie AST dla języka, być może w celu oceny, uproszczenia lub zebrania informacji. Kiama obsługuje Rewriting , zobacz przykłady w RewriterTests i obejrzyj ten film . Oto fragment, który zaostrzy apetyt:

// 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))

Zauważ, że Kiama wychodzi poza system czcionek, aby to osiągnąć.

retronim
źródło
2
Dla tych, którzy szukają zobowiązania. Oto jest: github.com/soundrabbit/scala/commit/… (chyba ..)
IttayD
15
Hej, gdzie są soczewki?
Daniel C. Sobral
Właśnie natknąłem się na ten problem i pomysł @zip brzmi naprawdę fantastycznie, może nawet powinien być wzięty tak daleko, że wszystkie klasy przypadków go mają? Dlaczego nie jest to realizowane? Soczewki są ładne, ale z dużymi i wieloma klasami / klasami obudów to tylko szablon, jeśli potrzebujesz tylko ustawiacza i nie ma nic nadzwyczajnego jak inkrementator.
Johan S
186

Zabawne, że nikt nie dodał soczewek, ponieważ zostały stworzone do tego typu rzeczy. Więc tutaj jest tło papieru CS na nim, tutaj jest blog, który na krótko dotykowy obiektywów używać w Scala, tutaj jest realizacja soczewki dla Scalaz i tutaj jest jakiś kod używając go, co wygląda zaskakująco podobne pytanie. I, aby zmniejszyć liczbę kotłów, oto wtyczka, która generuje soczewki Scalaz dla klas przypadków.

Jeśli chodzi o punkty dodatkowe, oto kolejne pytanie SO, które dotyczy soczewek i artykuł Tony'ego Morrisa.

Najważniejsze w soczewkach jest to, że można je komponować. Na początku są trochę uciążliwe, ale im częściej ich używasz, zyskują na popularności. Ponadto są świetne do testowania, ponieważ wystarczy przetestować pojedyncze soczewki i można przyjąć ich skład za pewnik.

Tak więc, w oparciu o implementację podaną na końcu tej odpowiedzi, oto jak to zrobić z soczewkami. Najpierw zadeklaruj soczewki do zmiany kodu pocztowego w adresie i adresu w osobie:

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))

Teraz utwórz je, aby uzyskać soczewkę, która zmienia kod pocztowy w osobie:

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

Na koniec użyj tego obiektywu, aby zmienić raj:

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

Lub używając cukru syntaktycznego:

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

Lub nawet:

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

Oto prosta implementacja, zaczerpnięta ze Scalaz, użyta w tym przykładzie:

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
}
Daniel C. Sobral
źródło
1
Możesz zaktualizować tę odpowiedź o opis wtyczki obiektywów Gerolfa Seitza.
missingfaktor
@missingfaktor Sure. Połączyć? Nie byłem świadomy takiej wtyczki.
Daniel C. Sobral
1
Kod personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)jest taki sam jakpersonZipCodeLens mod (raj, _ + 1)
ron
Jednak @ron modnie jest prymitywem dla soczewek.
Daniel C. Sobral
Tony Morris napisał na ten temat świetny artykuł . Myślę, że powinieneś połączyć to w swojej odpowiedzi.
missingfaktor
11

Przydatne narzędzia do korzystania z soczewek:

Chcę tylko dodać, że projekty Macrocosm i Rillit , oparte na makrach Scala 2.10, zapewniają dynamiczne tworzenie soczewek.


Korzystanie z 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))

Korzystanie z Macrocosm:

Działa to nawet w przypadku klas przypadków zdefiniowanych w bieżącym przebiegu kompilacji.

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
Sebastien Lorber
źródło
Prawdopodobnie przegapiłeś Rillit, który jest jeszcze lepszy. :-) github.com/akisaarinen/rillit
missingfaktor
Świetnie, sprawdzę
Sebastien Lorber
1
Przy okazji zredagowałem odpowiedź, aby uwzględnić Rillit, ale tak naprawdę nie rozumiem, dlaczego Rillit jest lepszy, wydaje się, że na pierwszy rzut oka zapewniają tę samą funkcjonalność w tej samej słowie @missingfaktor
Sebastien Lorber
@SebastienLorber Ciekawostka: Rillit jest Finem i oznacza Soczewki :)
Kai Sellgren
Wydaje się, że zarówno Macrocosm, jak i Rillit nie były aktualizowane w ciągu ostatnich 4 lat.
Erik van Oosten
9

Rozglądałem się za biblioteką Scala, która ma najładniejszą składnię i najlepszą funkcjonalność, a jedna biblioteka nie wymieniona tutaj to monokl, który dla mnie był naprawdę dobry. Oto przykład:

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"))

Są bardzo ładne i istnieje wiele sposobów łączenia soczewek. Na przykład Scalaz wymaga dużo gotowych rozwiązań, a to szybko się kompiluje i działa świetnie.

Aby użyć ich w swoim projekcie, po prostu dodaj to do swoich zależności:

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
)
Johan S
źródło
7

Shapeless załatwia sprawę:

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

z:

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)
    }
  }
}

Zauważ, że podczas gdy niektóre inne odpowiedzi tutaj pozwalają ci komponować soczewki, aby zagłębić się w daną strukturę, te bezkształtne soczewki (i inne biblioteki / makra) pozwalają ci połączyć dwie niepowiązane soczewki tak, że możesz zrobić soczewkę, która ustawia dowolną liczbę parametrów w dowolnych pozycjach w twojej strukturze. W przypadku złożonych struktur danych dodatkowa kompozycja jest bardzo pomocna.

simbo1905
źródło
Zauważ, że w końcu użyłem Lenskodu w odpowiedzi Daniela C. Sobrala i uniknąłem dodania zewnętrznej zależności.
simbo1905
7

Soczewki ze względu na swoją kompozycyjną naturę stanowią bardzo dobre rozwiązanie problemu mocno zagnieżdżonych struktur. Jednak przy niskim poziomie zagnieżdżenia czasami wydaje mi się, że soczewki są trochę za duże i nie chcę wprowadzać całego podejścia do soczewek, jeśli jest tylko kilka miejsc z zagnieżdżonymi aktualizacjami. W trosce o kompletność, oto bardzo proste / pragmatyczne rozwiązanie dla tego przypadku:

To, co robię, to po prostu napisanie kilku modify...funkcji pomocniczych w strukturze najwyższego poziomu, które zajmują się brzydką zagnieżdżoną kopią. Na przykład:

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

Mój główny cel (uproszczenie aktualizacji po stronie klienta) został osiągnięty:

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

Tworzenie pełnego zestawu pomocników modyfikacji jest oczywiście denerwujące. Jednak w przypadku elementów wewnętrznych często wystarczy je po prostu utworzyć przy pierwszej próbie zmodyfikowania określonego pola zagnieżdżonego.

bluenote10
źródło
4

Być może QuickLens lepiej pasuje do Twojego pytania. QuickLens używa makr do konwersji przyjaznego dla IDE wyrażenia na coś, co jest zbliżone do oryginalnej instrukcji copy.

Biorąc pod uwagę dwie przykładowe klasy przypadków:

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

oraz instancja klasy Person:

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

możesz zaktualizować kod pocztowy raj z:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
Erik van Oosten
źródło