Maneira mais limpa de atualizar estruturas aninhadas


124

Digamos que eu tenho as seguintes duas case classes:

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

e a seguinte instância da Personclasse:

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

Agora se eu quiser atualização zipCodede rajentão vou ter que fazer:

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

Com mais níveis de aninhamento, isso fica ainda mais feio. Existe uma maneira mais limpa (algo como a de Clojure update-in) de atualizar essas estruturas aninhadas?


1
Suponho que você deseja preservar a imutabilidade, caso contrário, basta colocar um var na frente da declaração de endereço da Pessoa.
GClaramunt

8
@ GClaramunt: Sim, quero preservar a imutabilidade.
missingfaktor

Respostas:


94

Zíperes

O Zipper de Huet fornece travessia conveniente e 'mutação' de uma estrutura de dados imutável. O Scalaz fornece zíperes para Stream( scalaz.Zipper ) e Tree( scalaz.TreeLoc ). Acontece que a estrutura do zíper é derivada automaticamente da estrutura de dados original, de maneira que se assemelha à diferenciação simbólica de uma expressão algébrica.

Mas como isso ajuda você com suas aulas de caso Scala? Bem, Lukas Rytz recentemente prototipou uma extensão do scalac que criaria automaticamente zíperes para classes de casos anotadas. Vou reproduzir o exemplo dele aqui:

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)

Portanto, a comunidade precisa convencer a equipe Scala de que esse esforço deve ser continuado e integrado ao compilador.

Aliás, Lukas publicou recentemente uma versão do Pacman, usuário programável através de uma DSL. Parece que ele não usou o compilador modificado, pois não consigo ver nenhuma @zipanotação.

Reescrita em Árvore

Em outras circunstâncias, convém aplicar alguma transformação em toda a estrutura de dados, de acordo com alguma estratégia (de cima para baixo, de baixo para cima) e com base em regras que correspondam ao valor em algum momento da estrutura. O exemplo clássico é transformar um AST para um idioma, talvez para avaliar, simplificar ou coletar informações. O Kiama suporta a reescrita , veja os exemplos em RewriterTests e assista a este vídeo . Aqui está um trecho para estimular seu apetite:

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

Observe que o Kiama sai do sistema de tipos para conseguir isso.


2
Para quem procura o commit. Aqui está: github.com/soundrabbit/scala/commit/… (eu acho ..)
IttayD 10/10

15
Ei, onde estão as lentes?
Daniel C. Sobral

Acabei de encontrar esse problema e a ideia do @zip parece realmente fantástica, talvez deva ser levada tão longe que todas as classes de casos têm? Por que isso não é implementado? As lentes são boas, mas com grandes e muitas classes / classes de caso, é apenas um padrão, se você quer apenas um setter e nada sofisticado como um incrementador.
Johan S

186

Engraçado que ninguém adicionou lentes, pois elas foram feitas para esse tipo de coisa. Então, aqui está um documento de plano de fundo da CS, aqui está um blog que aborda brevemente o uso de lentes no Scala, aqui está uma implementação de lentes para o Scalaz e aqui está um código usando-o, que se parece surpreendentemente com a sua pergunta. E, para reduzir a placa da caldeira, aqui está um plugin que gera lentes Scalaz para classes de casos.

Para pontos de bônus, aqui está outra questão do SO que toca nas lentes e um artigo de Tony Morris.

O grande problema das lentes é que elas são compostáveis. Portanto, eles são um pouco pesados ​​no começo, mas continuam ganhando terreno quanto mais você os usa. Além disso, eles são ótimos em termos de testabilidade, já que você só precisa testar lentes individuais e pode dar como certa sua composição.

Portanto, com base em uma implementação fornecida no final desta resposta, veja como você faria isso com lentes. Primeiro, declare as lentes para alterar um CEP em um endereço e um endereço em uma pessoa:

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

Agora, componha-as para obter uma lente que altera o CEP de uma pessoa:

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

Por fim, use essa lente para alterar o raj:

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

Ou, usando um pouco de açúcar sintático:

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

Ou até:

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

Aqui está a implementação simples, tirada do Scalaz, usada neste exemplo:

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
Você pode atualizar esta resposta com uma descrição do plugin de lentes de Gerolf Seitz.
missingfaktor

@missingfaktor Claro. Ligação? Eu não estava ciente desse plugin.
Daniel C. Sobral

1
O código personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)é o mesmo quepersonZipCodeLens mod (raj, _ + 1)
ron

O @ron modnão é um primitivo para as lentes.
Daniel C. Sobral

Tony Morris escreveu um ótimo artigo sobre o assunto. Eu acho que você deve vinculá-lo na sua resposta.
missingfaktor

11

Ferramentas úteis para usar lentes:

Só quero acrescentar que os projetos Macrocosm e Rillit , baseados nas macros Scala 2.10, oferecem criação dinâmica de lentes.


Usando o 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 o macrocosmo:

Isso funciona mesmo para as classes de casos definidas na execução de compilação atual.

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

Você provavelmente perdeu Rillit, o que é ainda melhor. :-) github.com/akisaarinen/rillit
missingfaktor

Bom, vou verificar isso #
Sebastien Lorber

1
Btw eu editei a minha resposta para incluir Rillit mas eu realmente não entendo por que Rillit é melhor, eles parece fornecer a mesma funcionalidade no mesmo verboseness à primeira @missingfaktor vista
Sebastien Lorber

@SebastienLorber Fun fato: Rillit é finlandeses e meios Lentes :)
Kai Sellgren

Macrocosmo e Rillit parecem não ser atualizados nos últimos 4 anos.
Erik van Oosten

9

Estive procurando ao redor da biblioteca Scala que tem a melhor sintaxe e a melhor funcionalidade, e uma biblioteca não mencionada aqui é o monóculo, o que para mim tem sido realmente bom. Um exemplo a seguir:

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ão muito legais e existem várias maneiras de combinar as lentes. O Scalaz, por exemplo, exige muito clichê e isso compila rapidamente e funciona muito bem.

Para usá-los em seu projeto, adicione-o às suas dependências:

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 faz o truque:

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

com:

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

Observe que, embora algumas outras respostas aqui permitam compor lentes para aprofundar uma determinada estrutura, essas lentes sem forma (e outras bibliotecas / macros) permitem combinar duas lentes não relacionadas, de modo que você pode criar lentes que definem um número arbitrário de parâmetros em posições arbitrárias na sua estrutura. Para estruturas de dados complexas, essa composição adicional é muito útil.


Note que acabei usando o Lenscódigo na resposta de Daniel C. Sobral e, portanto, evitei adicionar uma dependência externa.
precisa saber é o seguinte

7

Devido à sua natureza compostável, as lentes oferecem uma solução muito agradável para o problema de estruturas fortemente aninhadas. No entanto, com um baixo nível de aninhamento, às vezes sinto que as lentes são um pouco demais e não quero introduzir a abordagem de lentes inteiras se houver apenas alguns lugares com atualizações aninhadas. Por uma questão de integridade, aqui está uma solução muito simples / pragmática para este caso:

O que faço é simplesmente escrever algumas modify...funções auxiliares na estrutura de nível superior, que lidam com a cópia aninhada feia. Por exemplo:

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

Meu principal objetivo (simplificar a atualização no lado do cliente) é alcançado:

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

Criar o conjunto completo de auxiliares de modificação é obviamente irritante. Mas para coisas internas, geralmente é bom apenas criá-las na primeira vez que você tenta modificar um determinado campo aninhado.


4

Talvez o QuickLens corresponda melhor à sua pergunta. O QuickLens usa macro para converter uma expressão amigável do IDE em algo próximo à instrução de cópia original.

Dadas as duas classes de casos de exemplo:

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

e a instância da classe Person:

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

você pode atualizar o zipCode do raj com:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.