Modo più pulito per aggiornare le strutture nidificate


124

Di 'che ho i seguenti due case classes:

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

e la seguente istanza di Personclasse:

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

Ora se voglio aggiornare zipCodedi rajallora dovrò fare:

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

Con più livelli di nidificazione questo diventa ancora più brutto. Esiste un modo più pulito (qualcosa come quello di Clojure update-in) per aggiornare tali strutture nidificate?


1
Presumo che tu voglia preservare l'immutabilità, altrimenti attacca semplicemente un var davanti alla dichiarazione di indirizzo di persone.
GClaramunt,

8
@GClaramunt: Sì, voglio preservare l'immutabilità.
missingfaktor,

Risposte:


94

cerniere

La cerniera di Huet offre un comodo attraversamento e "mutazione" di una struttura di dati immutabile. Scalaz fornisce cerniere per Stream( scalaz.Zipper ) e Tree( scalaz.TreeLoc ). Si scopre che la struttura della cerniera è automaticamente derivabile dalla struttura dati originale, in un modo che ricorda la differenziazione simbolica di un'espressione algebrica.

Ma come può aiutarti con le tue lezioni sul caso Scala? Bene, Lukas Rytz ha recentemente prototipato un'estensione per scalac che creerebbe automaticamente cerniere per classi di casi annotate. Riprodurrò il suo esempio qui:

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)

Quindi la comunità deve convincere il team di Scala che questo sforzo dovrebbe essere continuato e integrato nel compilatore.

Per inciso, Lukas ha recentemente pubblicato una versione di Pacman, programmabile dall'utente tramite un DSL. Non sembra che abbia usato il compilatore modificato, poiché non riesco a vedere alcuna @zipannotazione.

Riscrittura degli alberi

In altre circostanze, è possibile applicare alcune trasformazioni all'intera struttura dei dati, in base a una strategia (top-down, bottom-up) e in base a regole che corrispondono al valore in un determinato punto della struttura. L'esempio classico sta trasformando un AST in una lingua, forse per valutare, semplificare o raccogliere informazioni. Kiama supporta la riscrittura , guarda gli esempi in RewriterTest e guarda questo video . Ecco uno snippet per stimolare l'appetito:

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

Si noti che Kiama passi al di fuori del sistema di tipi per raggiungere questo obiettivo.


2
Per chi cerca l'impegno. Eccolo: github.com/soundrabbit/scala/commit/… (penso ..)
IttayD

15
Ehi, dove sono le lenti?
Daniel C. Sobral,

Ho appena riscontrato questo problema e l'idea di @zip sembra davvero fantastica, forse dovrebbe essere portata così lontano che tutte le classi di casi lo hanno? Perché questo non è implementato? Le lenti sono belle ma con grandi e molte classi / classi di casi è solo una piastra se vuoi solo un setter e niente di speciale come un incrementatore.
Johan S,

186

Divertente che nessuno abbia aggiunto obiettivi, dal momento che sono stati realizzati per questo tipo di cose. Quindi, ecco un documento di approfondimento su CS, ecco un blog che tocca brevemente l'uso degli obiettivi in ​​Scala, ecco un'implementazione degli obiettivi per Scalaz e qui c'è un po 'di codice che lo utilizza, che assomiglia sorprendentemente alla tua domanda. E, per ridurre la piastra della caldaia, ecco un plugin che genera obiettivi Scalaz per le classi di custodie.

Per i punti bonus, ecco un'altra domanda SO che tocca gli obiettivi e un articolo di Tony Morris.

Il grosso problema delle lenti è che sono componibili. Quindi all'inizio sono un po 'ingombranti, ma continuano a guadagnare terreno più li usi. Inoltre, sono ottimi per la testabilità, dal momento che devi solo testare i singoli obiettivi e puoi dare per scontata la loro composizione.

Quindi, sulla base di un'implementazione fornita alla fine di questa risposta, ecco come lo faresti con gli obiettivi. Innanzitutto, dichiarare agli obiettivi di modificare un codice postale in un indirizzo e un indirizzo in una persona:

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

Ora componili per ottenere un obiettivo che cambi il codice postale in una persona:

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

Infine, usa quell'obiettivo per cambiare raj:

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

Oppure, usando un po 'di zucchero sintattico:

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

O anche:

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

Ecco la semplice implementazione, presa da Scalaz, utilizzata per questo esempio:

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
Potresti voler aggiornare questa risposta con una descrizione del plugin per obiettivi di Gerolf Seitz.
missingfaktor,

@missingfaktor Certo. Link? Non ero a conoscenza di tale plugin.
Daniel C. Sobral,

1
Il codice personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)è lo stesso dipersonZipCodeLens mod (raj, _ + 1)
ron

Tuttavia, @ron modnon è un primitivo per le lenti.
Daniel C. Sobral,

Tony Morris ha scritto un ottimo articolo sull'argomento. Penso che dovresti collegarlo nella tua risposta.
missingfaktor il

11

Strumenti utili per usare gli obiettivi:

Voglio solo aggiungere che i progetti Macrocosm e Rillit , basati su macro Scala 2.10, forniscono la creazione dinamica dell'obiettivo.


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

Utilizzando Macrocosm:

Questo funziona anche con le classi di casi definite nell'esecuzione di compilazione corrente.

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

Probabilmente ti sei perso Rillit che è ancora meglio. :-) github.com/akisaarinen/rillit
missingfaktor

Bene, lo verificherò
Sebastien Lorber

1
Tra l'altro ho modificato la mia risposta per includere Rillit ma non capisco davvero perché Rillit sia migliore, sembrano fornire la stessa funzionalità con la stessa verbosità a prima vista @missingfaktor
Sebastien Lorber

@SebastienLorber Curiosità: Rillit è finlandese e significa obiettivi :)
Kai Sellgren

Sia Macrocosm che Rillit sembrano non essere aggiornati negli ultimi 4 anni.
Erik van Oosten,

9

Ho cercato la libreria Scala che ha la sintassi più bella e la migliore funzionalità e una libreria non menzionata qui è il monocolo che per me è stato davvero buono. Un esempio segue:

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

Questi sono molto belli e ci sono molti modi per combinare le lenti. Ad esempio, Scalaz richiede molta caldaia e questo si compila rapidamente e funziona alla grande.

Per usarli nel tuo progetto basta aggiungere questo alle tue dipendenze:

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

Shapeless fa il trucco:

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

con:

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

Si noti che mentre alcune altre risposte qui consentono di comporre obiettivi per approfondire una data struttura, questi obiettivi shapless (e altre librerie / macro) consentono di combinare due obiettivi non correlati in modo da poter creare obiettivi che impostano un numero arbitrario di parametri in posizioni arbitrarie nella tua struttura. Per strutture di dati complesse questa composizione aggiuntiva è molto utile.


Nota che alla fine ho finito per usare il Lenscodice nella risposta di Daniel C. Sobral e quindi ho evitato di aggiungere una dipendenza esterna.
simbo1905,

7

Grazie alla loro natura componibile, le lenti offrono una soluzione molto piacevole al problema delle strutture fortemente annidate. Tuttavia, con un basso livello di annidamento, a volte sento che gli obiettivi sono un po 'troppo e non voglio introdurre l'intero approccio degli obiettivi se ci sono solo pochi posti con aggiornamenti nidificati. Per completezza, ecco una soluzione molto semplice / pragmatica per questo caso:

Quello che faccio è semplicemente scrivere alcune modify...funzioni di supporto nella struttura di livello superiore, che si occupano della brutta copia nidificata. Per esempio:

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

Il mio obiettivo principale (semplificare l'aggiornamento sul lato client) è raggiunto:

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

Creare il set completo di helper di modifica è ovviamente fastidioso. Ma per cose interne spesso va bene semplicemente crearle la prima volta che si tenta di modificare un determinato campo nidificato.


4

Forse QuickLens meglio la tua domanda. QuickLens utilizza le macro per convertire un'espressione IDE amichevole in qualcosa che è vicino all'istruzione di copia originale.

Date le due classi di casi di esempio:

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

e l'istanza della classe Person:

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

puoi aggiornare zipCode di raj con:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.