Forma más limpia de actualizar estructuras anidadas

124

Digamos que tengo los siguientes dos case classes:

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

y la siguiente instancia de Personclase:

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

Ahora si quiero actualizar zipCodede rajentonces tendré que hacer:

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

Con más niveles de anidación, esto se vuelve aún más feo. ¿Hay una forma más limpia (algo así como Clojure update-in) de actualizar estas estructuras anidadas?

missingfaktor
fuente
1
Supongo que desea preservar la inmutabilidad, de lo contrario, solo pegue una var frente a la declaración de dirección de las Personas.
GClaramunt
8
@GClaramunt: Sí, quiero preservar la inmutabilidad.
missingfaktor

Respuestas:

94

Cremalleras

Huet's Zipper proporciona un recorrido conveniente y una 'mutación' de una estructura de datos inmutable. Scalaz proporciona cremalleras para Stream( scalaz.Zipper ) y Tree( scalaz.TreeLoc ). Resulta que la estructura de la cremallera se deriva automáticamente de la estructura de datos original, de una manera que se asemeja a la diferenciación simbólica de una expresión algebraica.

Pero, ¿cómo te ayuda esto con tus clases de casos Scala? Bueno, Lukas Rytz recientemente creó un prototipo de una extensión para scalac que crearía automáticamente cremalleras para clases de casos anotados. Reproduciré su ejemplo aquí:

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)

Por lo tanto, la comunidad necesita persuadir al equipo de Scala de que este esfuerzo debe continuar e integrarse en el compilador.

Por cierto, Lukas publicó recientemente una versión de Pacman, programable por el usuario a través de un DSL. Sin embargo, no parece que haya usado el compilador modificado, ya que no puedo ver ninguna @zipanotación.

Reescritura de árboles

En otras circunstancias, es posible que desee aplicar alguna transformación en toda la estructura de datos, de acuerdo con alguna estrategia (de arriba hacia abajo, de abajo hacia arriba) y en función de las reglas que coinciden con el valor en algún punto de la estructura. El ejemplo clásico es transformar un AST para un idioma, tal vez para evaluar, simplificar o recopilar información. Kiama admite Reescritura , vea los ejemplos en RewriterTests y vea este video . Aquí hay un fragmento para abrir el apetito:

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

Tenga en cuenta que Kiama sale del sistema de tipos para lograr esto.

retrónimo
fuente
2
Para aquellos que buscan el compromiso. Aquí está: github.com/soundrabbit/scala/commit/… (creo que ...)
IttayD
15
Oye, ¿dónde están las lentes?
Daniel C. Sobral
Acabo de encontrar este problema y la idea de @zip suena realmente fantástica, ¿tal vez debería llevarse tan lejos que todas las clases de casos la tengan? ¿Por qué no se implementa esto? Las lentes son agradables, pero con grandes y muchas clases / clases de casos es simplemente repetitivo si solo quieres un setter y nada lujoso como un incrementador.
Johan S
186

Es curioso que nadie haya agregado lentes, ya que fueron HECHOS para este tipo de cosas. Entonces, aquí hay un documento de antecedentes de CS sobre él, aquí hay un blog que trata brevemente sobre el uso de lentes en Scala, aquí hay una implementación de lentes para Scalaz y aquí hay un código que lo usa, que sorprendentemente se parece a su pregunta. Y, para reducir la placa de la caldera, aquí hay un complemento que genera lentes Scalaz para clases de casos.

Para obtener puntos de bonificación, aquí hay otra pregunta SO que toca lentes y un artículo de Tony Morris.

Lo importante de las lentes es que son compostables. Por lo tanto, son un poco engorrosos al principio, pero siguen ganando terreno cuanto más los usas. Además, son excelentes para la capacidad de prueba, ya que solo necesita probar lentes individuales y puede dar por sentado su composición.

Entonces, basado en una implementación proporcionada al final de esta respuesta, así es como lo haría con lentes. Primero, declare lentes para cambiar un código postal en una dirección y una dirección en 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))

Ahora, compónelos para obtener una lente que cambie el código postal de una persona:

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

Finalmente, use esa lente para cambiar raj:

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

O, usando un poco de azúcar sintáctica:

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

O incluso:

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

Aquí está la implementación simple, tomada de Scalaz, utilizada para este ejemplo:

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
fuente
1
Es posible que desee actualizar esta respuesta con una descripción del complemento de lentes de Gerolf Seitz.
missingfaktor
@missingfaktor Claro. ¿Enlace? No estaba al tanto de tal complemento.
Daniel C. Sobral
1
El código personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)es el mismo quepersonZipCodeLens mod (raj, _ + 1)
ron
Sin embargo, @ron modno es primitivo para lentes.
Daniel C. Sobral
Tony Morris ha escrito un gran artículo sobre el tema. Creo que deberías vincularlo en tu respuesta.
missingfaktor
11

Herramientas útiles para usar lentes:

Solo quiero agregar que los proyectos Macrocosmos y Rillit , basados ​​en macros Scala 2.10, proporcionan la creación dinámica de lentes.


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

Usando Macrocosmos:

Esto incluso funciona para las clases de casos definidas en la ejecución de compilación actual.

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
fuente
Probablemente te perdiste Rillit, que es aún mejor. :-) github.com/akisaarinen/rillit
missingfaktor
Bien, lo comprobaré
Sebastien Lorber,
1
Por cierto, edité mi respuesta para incluir a Rillit, pero realmente no entiendo por qué Rillit es mejor, parecen proporcionar la misma funcionalidad con la misma verbosidad a primera vista @missingfaktor
Sebastien Lorber
@SebastienLorber Dato curioso: Rillit es finlandesa y significa lentes :)
Kai Sellgren
Tanto Macrocosm como Rillit parecen no estar actualizados en los últimos 4 años.
Erik van Oosten
9

He estado buscando qué biblioteca de Scala que tenga la mejor sintaxis y la mejor funcionalidad y una biblioteca que no se menciona aquí es el monóculo, que para mí ha sido realmente bueno. A continuación se muestra un ejemplo:

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

Estos son muy agradables y hay muchas formas de combinar las lentes. Scalaz, por ejemplo, exige una gran cantidad de repeticiones y esto se compila rápido y funciona muy bien.

Para usarlos en su proyecto simplemente agregue esto a sus dependencias:

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

Sin forma hace el truco:

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

Tenga en cuenta que si bien algunas otras respuestas aquí le permiten componer lentes para profundizar en una estructura dada, estas lentes sin forma (y otras bibliotecas / macros) le permiten combinar dos lentes no relacionadas de modo que pueda hacer lentes que establezcan un número arbitrario de parámetros en posiciones arbitrarias en tu estructura Para estructuras de datos complejas, esa composición adicional es muy útil.

simbo1905
fuente
Tenga en cuenta que eventualmente terminé usando el Lenscódigo en la respuesta de Daniel C. Sobral y evité agregar una dependencia externa.
simbo1905
7

Debido a su naturaleza composable, las lentes proporcionan una solución muy agradable al problema de las estructuras muy anidadas. Sin embargo, con un bajo nivel de anidamiento, a veces siento que las lentes son demasiado, y no quiero presentar el enfoque de lentes completos si solo hay pocos lugares con actualizaciones anidadas. En aras de la exhaustividad, aquí hay una solución muy simple / pragmática para este caso:

Lo que hago es simplemente escribir algunas modify...funciones auxiliares en la estructura de nivel superior, que se ocupan de la copia anidada fea. Por ejemplo:

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

Mi objetivo principal (simplificar la actualización en el lado del cliente) se logra:

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

Crear el conjunto completo de ayudantes de modificación es obviamente molesto. Pero para cosas internas, a menudo está bien crearlas la primera vez que intente modificar un determinado campo anidado.

bluenote10
fuente
4

Quizás QuickLens coincida mejor con su pregunta. QuickLens usa macros para convertir una expresión amigable IDE en algo que está cerca de la declaración de copia original.

Dados los dos ejemplos de clases de casos:

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

y la instancia de la clase Person:

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

puede actualizar el código postal de raj con:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
Erik van Oosten
fuente