Une manière plus propre de mettre à jour les structures imbriquées


124

Disons que j'ai les deux suivants case class:

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

et l'instance de Personclasse suivante:

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

Maintenant , si je veux mettre à jour zipCodede rajalors je vais devoir faire:

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

Avec plus de niveaux de nidification, cela devient encore plus laid. Existe-t-il un moyen plus propre (quelque chose comme celui de Clojure update-in) de mettre à jour de telles structures imbriquées?


1
Je suppose que vous voulez préserver l'immuabilité, sinon, collez simplement un var devant la déclaration d'adresse des personnes.
GClaramunt

8
@GClaramunt: Oui, je veux préserver l'immuabilité.
missingfaktor

Réponses:


94

Fermetures éclair

La fermeture éclair de Huet permet une traversée et une «mutation» pratiques d'une structure de données immuable. Scalaz fournit des fermetures éclair pour Stream( scalaz.Zipper ) et Tree( scalaz.TreeLoc ). Il s'avère que la structure de la fermeture à glissière est automatiquement dérivable de la structure de données d'origine, d'une manière qui ressemble à la différenciation symbolique d'une expression algébrique.

Mais comment cela vous aide-t-il avec vos classes de cas Scala? Eh bien, Lukas Rytz a récemment prototypé une extension de scalac qui créerait automatiquement des fermetures à glissière pour les classes de cas annotées. Je vais reproduire son exemple ici:

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)

La communauté doit donc persuader l'équipe Scala que cet effort doit être poursuivi et intégré dans le compilateur.

Incidemment, Lukas a récemment publié une version de Pacman, programmable par l'utilisateur via un DSL. Il ne semble pas qu'il ait utilisé le compilateur modifié, car je ne vois aucune @zipannotation.

Réécriture d'arbres

Dans d'autres circonstances, vous souhaiterez peut-être appliquer une transformation à l'ensemble de la structure de données, selon une stratégie (descendante, ascendante) et basée sur des règles qui correspondent à la valeur à un moment donné de la structure. L'exemple classique consiste à transformer un AST pour une langue, peut-être pour évaluer, simplifier ou collecter des informations. Kiama prend en charge la réécriture , consultez les exemples dans RewriterTests et regardez cette vidéo . Voici un extrait pour vous mettre en appétit:

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

Notez que Kiama étapes en dehors du système de type pour y parvenir.


2
Pour ceux qui recherchent le commit. Le voici: github.com/soundrabbit/scala/commit/… (je pense ..)
IttayD

15
Hé, où sont les lentilles?
Daniel C.Sobral

Je viens de rencontrer ce problème et l'idée de @zip semble vraiment fantastique, peut-être qu'elle devrait même être poussée si loin que toutes les classes de cas l'ont? Pourquoi n'est-ce pas implémenté? Les objectifs sont agréables, mais avec de grandes et de nombreuses classes / classes de cas, ce n'est que du passe-partout si vous voulez juste un setter et rien d'extraordinaire comme un incrémenteur.
Johan S

186

C'est drôle que personne n'ait ajouté d'objectifs, car ils étaient FABRIQUÉS pour ce genre de choses. Donc, voici un document de base CS à ce sujet, voici un blog qui aborde brièvement l'utilisation des objectifs dans Scala, voici une implémentation d'objectifs pour Scalaz et voici un code qui l'utilise, qui ressemble étonnamment à votre question. Et, pour réduire la quantité de plaques chauffantes, voici un plugin qui génère des lentilles Scalaz pour les classes de cas.

Pour les points bonus, voici une autre question SO qui touche aux lentilles, et un article de Tony Morris.

Le gros problème avec les objectifs est qu'ils sont composables. Ils sont donc un peu encombrants au début, mais ils gagnent du terrain à mesure que vous les utilisez. En outre, ils sont parfaits pour la testabilité, car vous n'avez besoin que de tester des lentilles individuelles et vous pouvez tenir pour acquise leur composition.

Donc, sur la base d'une implémentation fournie à la fin de cette réponse, voici comment procéder avec des objectifs. Tout d'abord, déclarez les lentilles pour changer un code postal dans une adresse et une adresse chez une personne:

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

Maintenant, composez-les pour obtenir une lentille qui change le code postal d'une personne:

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

Enfin, utilisez cet objectif pour changer de raj:

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

Ou, en utilisant du sucre syntaxique:

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

Ou même:

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

Voici l'implémentation simple, tirée de Scalaz, utilisée pour cet exemple:

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
Vous voudrez peut-être mettre à jour cette réponse avec une description du plugin de verres de Gerolf Seitz.
missingfaktor

@missingfaktor Bien sûr. Lien? Je n'étais pas au courant d'un tel plugin.
Daniel C. Sobral

1
Le code personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)est le même quepersonZipCodeLens mod (raj, _ + 1)
ron

@ron modn'est cependant pas une primitive pour les objectifs.
Daniel C.Sobral

Tony Morris a écrit un excellent article sur le sujet. Je pense que vous devriez le lier dans votre réponse.
missingfaktor

11

Outils utiles pour utiliser les objectifs:

Je veux juste ajouter que les projets Macrocosm et Rillit , basés sur des macros Scala 2.10, fournissent la création dynamique de lentilles.


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

Utilisation du macrocosme:

Cela fonctionne même pour les classes de cas définies dans la compilation en cours.

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

Vous avez probablement manqué Rillit qui est encore mieux. :-) github.com/akisaarinen/rillit
missingfaktor

Nice, vérifiera ça
Sebastien Lorber

1
Btw j'ai édité ma réponse pour inclure Rillit mais je ne comprends pas vraiment pourquoi Rillit est meilleur, ils semblent fournir la même fonctionnalité dans la même verbosité à première vue @missingfaktor
Sebastien Lorber

@SebastienLorber Fait amusant: Rillit est finnois et signifie lentilles :)
Kai Sellgren

Macrocosm et Rillit ne semblent pas avoir été mis à jour au cours des 4 dernières années.
Erik van Oosten

9

J'ai cherché quelle bibliothèque Scala qui a la plus belle syntaxe et la meilleure fonctionnalité et une bibliothèque non mentionnée ici est monocle qui pour moi a été vraiment bonne. Un exemple suit:

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

Celles-ci sont très belles et il existe de nombreuses façons de combiner les lentilles. Scalaz, par exemple, exige beaucoup de passe-partout et cela se compile rapidement et fonctionne très bien.

Pour les utiliser dans votre projet, ajoutez simplement ceci à vos dépendances:

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 fait l'affaire:

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

avec:

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

Notez que bien que d'autres réponses ici vous permettent de composer des objectifs pour aller plus loin dans une structure donnée, ces objectifs sans faille (et d'autres bibliothèques / macros) vous permettent de combiner deux objectifs non liés de sorte que vous puissiez créer un objectif qui définit un nombre arbitraire de paramètres dans des positions arbitraires. dans votre structure. Pour les structures de données complexes, cette composition supplémentaire est très utile.


Notez que j'ai fini par utiliser le Lenscode dans la réponse de Daniel C. Sobral et ainsi évité d'ajouter une dépendance externe.
simbo1905

7

En raison de leur nature composable, les lentilles offrent une très belle solution au problème des structures fortement imbriquées. Cependant, avec un faible niveau d'imbrication, j'ai parfois l'impression que les objectifs sont un peu trop importants, et je ne veux pas introduire l'approche globale des objectifs s'il n'y a que quelques endroits avec des mises à jour imbriquées. Par souci d'exhaustivité, voici une solution très simple / pragmatique pour ce cas:

Ce que je fais, c'est simplement écrire quelques modify...fonctions d'aide dans la structure de niveau supérieur, qui traitent de la vilaine copie imbriquée. Par exemple:

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

Mon objectif principal (simplifier la mise à jour côté client) est atteint:

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

La création de l'ensemble complet des aides de modification est évidemment ennuyeuse. Mais pour les éléments internes, il est souvent acceptable de les créer simplement la première fois que vous essayez de modifier un certain champ imbriqué.


4

Peut-être que QuickLens correspond mieux à votre question. QuickLens utilise des macros pour convertir une expression conviviale IDE en quelque chose qui est proche de l'instruction de copie d'origine.

Compte tenu des deux exemples de classes de cas:

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

et l'instance de la classe Person:

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

vous pouvez mettre à jour zipCode de raj avec:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.