更新嵌套结构的更干净方法


124

说我有以下两个case classES:

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

和以下Person类的实例:

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

现在,如果我要更新zipCoderaj则必须做:

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

随着嵌套级别的增加,这变得更加难看。有没有更清洁的方法(例如Clojure的方法update-in)来更新此类嵌套结构?


1
我假设您想保留不变性,否则,只需在“人员”的地址声明前添加一个var。
GClaramunt

8
@GClaramunt:是的,我想保留不变性。
missingfaktor

Answers:


94

拉链

Huet的Zipper提供了便捷的遍历和不可变数据结构的“变异”。Scalaz为Streamscalaz.Zipper)和Treescalaz.TreeLoc)提供了拉链。事实证明,拉链的结构可以自动地从原始数据结构派生,其方式类似于代数表达式的符号区分。

但是,这对Scala案例类有什么帮助?好吧,Lukas Rytz最近为scalac扩展创建了原型,该扩展将自动为带注释的案例类创建拉链。我将在这里重现他的示例:

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)

因此社区需要说服Scala团队继续进行这项工作并将其集成到编译器中。

顺便说一句,卢卡斯(Lukas)最近发布了Pacman的一个版本,可以通过DSL对其进行编程。不过,看起来好像他没有使用修改过的编译器,因为我看不到任何@zip注释。

树重写

在其他情况下,您可能希望根据某种策略(自上而下,自下而上)并基于与该结构中某个点的值匹配的规则对整个数据结构应用某种转换。经典示例是将AST转换为一种语言,也许是为了评估,简化或收集信息。Kiama支持重写,请参阅RewriterTests中的示例,并观看此视频。这是一个片段,可以激发您的胃口:

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

请注意,Kiama 超越了类型系统来实现此目的。


2
对于那些寻找提交的人。在这里是:github.com/soundrabbit/scala/commit/…(我想..)
IttayD 2010年

15
嘿,镜头在哪里?
Daniel C. Sobral

我刚刚遇到了这个问题,@zip的想法听起来真是太棒了,也许它甚至应该被应用到所有案例类都拥有吗?为什么不实施呢?镜头很不错,但是有很多类/案例类,如果您只想使用二传手而又不喜欢增量器的话,那就只是样板。
Johan S

186

有趣的是,没有人添加任何镜头,因为它们是为这种东西而制造的。因此,是CS的背景资料,是一个博客,简要介绍了Scala中的镜头使用,是Scalaz的镜头实现,是一些使用它的代码,看起来很像您的问题。而且,为了减少样板,这是一个为案例类生成Scalaz镜头的插件。

对于奖励积分,这是另一个涉及镜片的SO问题,以及Tony Morris 的论文

镜片的重要之处在于它们是可组合的。因此,它们起初有点麻烦,但是随着您使用它们的增多,它们会不断发展。此外,它们还具有很好的可测性,因为您只需要测试单个镜片,并且可以理会它们的成分。

因此,根据此答案结尾处提供的实现,以下是使用镜头进行操作的方法。首先,声明镜片以更改地址中的邮政编码和人中的地址:

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

现在,组成它们以获取可以更改人的邮政编码的镜头:

val personZipCodeLens = personAddressLens andThen addressZipCodeLens

最后,使用该镜头更改raj:

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

或者,使用一些语法糖:

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

甚至:

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

这是从Scalaz提取的简单实现,用于此示例:

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
您可能需要用Gerolf Seitz的lens插件的描述来更新此答案。
missingfaktor 2011年

@missingfaktor好的。链接?我不知道这样的插件。
Daniel C. Sobral

1
验证码personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)personZipCodeLens mod (raj, _ + 1)
ron 2012年

不过,@ ron mod对于镜头而言并不是原始的。
Daniel C. Sobral 2012年

托尼·莫里斯(Tony Morris)就该主题写了一篇出色的论文。我认为您应该将其链接到您的答案中。
missingfaktor 2012年

11

使用镜头的有用工具:

只是要补充一点,基于Scala 2.10宏的MacrocosmRillit项目提供了动态镜头创建功能。


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

使用Macrocosm:

这甚至适用于当前编译运行中定义的案例类。

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

您可能会错过Rillit,这甚至更好。:-) github.com/akisaarinen/rillit
missingfaktor 2013年

尼斯,请检查一下
塞巴斯蒂安·洛伯

1
顺便说一句,我编辑了我的答案以包括Rillit,但我真的不明白为什么Rillit会更好,它们乍一看似乎以相同的冗长提供了相同的功能@missingfaktor
Sebastien Lorber

@SebastienLorber有趣的事实:Rillit是芬兰人,意思是镜头:)
Kai Sellgren

Macrocosm和Rillit似乎都没有在最近4年中进行更新。
埃里克·范·奥斯汀

9

我一直在寻找具有最佳语法和最佳功能的Scala库,这里没有提到的一个库是monocle,对我来说确实很棒。下面是一个示例:

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

这些非常好,并且有很多组合镜头的方法。例如,Scalaz需要大量样板,并且编译迅速且运行良好。

要在项目中使用它们,只需将其添加到依赖项中:

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做到了:

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

与:

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

请注意,虽然这里有一些其他答案,但您可以使镜头更深入到给定的结构中,而这些无矫正镜头(和其他库/宏)可以让您组合两个不相关的镜头,从而可以使镜头将任意数量的参数设置到任意位置在你的结构中。对于复杂的数据结构,额外的组合非常有帮助。


请注意,我最终使用了LensDaniel C. Sobral的答案中的代码,因此避免了添加外部依赖项。
simbo1905

7

由于其可组合的性质,镜片为解决严重嵌套的结构提供了很好的解决方案。但是,由于嵌套水平较低,我有时会觉得镜头太多了,如果只有很少的地方嵌套更新,我不想介绍整个镜头方法。为了完整起见,以下是针对这种情况的非常简单/实用的解决方案:

我要做的是简单地modify...在顶层结构中编写一些辅助函数,以处理丑陋的嵌套副本。例如:

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

我的主要目标(简化客户端更新)得以实现:

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

创建全套修改助手显然很烦人。但是对于内部的东西,通常可以在您第一次尝试修改某个嵌套字段时就创建它们。


4

也许QuickLens适合您的问题。QuickLens使用宏将IDE友好的表达式转换为与原始copy语句接近的表达式。

给定两个示例案例类:

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

和Person类的实例:

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

您可以使用以下命令更新raj的zipCode:

import com.softwaremill.quicklens._
val updatedRaj = raj.modify(_.address.zipCode).using(_ + 1)
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.