【问题标题】:Cleaner way to update nested structures更新嵌套结构的更简洁方法
【发布时间】:2011-04-23 10:52:54
【问题描述】:

假设我有以下两个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))

现在如果我想更新raj 中的zipCode,那么我将不得不这样做:

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

随着嵌套层次的增加,这变得更加丑陋。有没有更简洁的方法(比如 Clojure 的 update-in)来更新这种嵌套结构?

【问题讨论】:

  • 我假设你想保持不变性,否则,只需在 Person 的地址声明前添加一个 var。
  • @GClaramunt:是的,我想保持不变性。

标签: scala case-class zipper


【解决方案1】:

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

给定两个示例案例类:

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 的邮政编码:

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

【讨论】:

    【解决方案2】:

    由于其可组合的特性,镜头为解决严重嵌套结构的问题提供了一个非常好的解决方案。但是由于嵌套级别低,我有时会觉得镜头有点太多,如果只有很少的地方嵌套更新,我不想介绍整个镜头的方法。为了完整起见,这里有一个非常简单/实用的解决方案:

    我所做的只是简单地在顶层结构中编写一些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)
    

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

    【讨论】:

      【解决方案3】:

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

      请注意,虽然此处的其他一些答案可让您组合镜头以更深入地了解给定结构,但这些无形镜头(和其他库/宏)可让您组合两个不相关的镜头,以便您可以制作设置任意数量参数的镜头进入结构中的任意位置。对于复杂的数据结构,额外的组合非常有用。

      【讨论】:

      • 请注意,我最终在 Daniel C. Sobral 的回答中使用了 Lens 代码,因此避免添加外部依赖项。
      【解决方案4】:

      有趣的是没有人添加镜头,因为它们是为这种东西制造的。所以,here 是关于它的 CS 背景论文,here 是一个简要介绍 Scala 中使用的镜头的博客,here 是 Scalaz 的镜头实现,here 是一些使用它的代码,看起来令人惊讶喜欢你的问题。而且,为了减少样板,here's 是一个为案例类生成 Scalaz 镜头的插件。

      对于奖励积分,here's 另一个 S.O.涉及镜头的问题,以及 Tony Morris 的 paper

      镜头的重要之处在于它们是可组合的。所以一开始它们有点麻烦,但你使用它们的次数越多,它们就会越来越受欢迎。此外,它们非常适合可测试性,因为您只需要测试单个镜头,并且可以认为它们的组成是理所当然的。

      因此,根据此答案末尾提供的实现,以下是使用镜头的方法。首先,声明镜头更改地址中的邮政编码,以及人员中的地址:

      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
      }
      

      【讨论】:

      • 您可能想用 Gerolf Seitz 的镜头插件的描述来更新这个答案。
      • @missingfaktor 当然。关联?我不知道有这样的插件。
      • 代码personZipCodeLens.set(raj, personZipCodeLens.get(raj) + 1)personZipCodeLens mod (raj, _ + 1)相同
      • 不过,@ron mod 并不是镜头的原语。
      • 托尼莫里斯写了a great paper关于这个主题。我认为您应该将其链接到您的答案中。
      【解决方案5】:

      使用镜头的有用工具:

      只想补充一点,MacrocosmRillit 项目基于 Scala 2.10 宏,提供动态镜头创建。


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

      使用宏观世界:

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

      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
      • 顺便说一句,我编辑了我的答案以包括 Rillit,但我真的不明白为什么 Rillit 更好,它们似乎提供了相同的功能,乍一看@missingfaktor
      • @SebastienLorber 有趣的事实:Rillit 是芬兰语,意思是 Lenses :)
      • Macrocosm 和 Rillit 似乎在过去 4 年没有更新。
      【解决方案6】:

      我一直在寻找哪个 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】:

        拉链

        Huet's Zipper 提供了对不可变数据结构的便捷遍历和“变异”。 Scalaz 为Stream (scalaz.Zipper) 和Tree (scalaz.TreeLoc) 提供拉链。事实证明,zipper 的结构是从原始数据结构自动推导出来的,其方式类似于代数表达式的符号微分。

        但这对您的 Scala 案例类有何帮助?好吧,Lukas Rytz 最近prototyped 是 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 最近published 是 Pacman 的一个版本,用户可以通过 DSL 进行编程。不过,看起来他没有使用修改后的编译器,因为我看不到任何 @zip 注释。

        树重写

        在其他情况下,您可能希望根据某种策略(自上而下、自下而上)并基于与结构中某个点的值匹配的规则,对整个数据结构应用某种转换。经典的例子是将 AST 转换为一种语言,可能是为了评估、简化或收集信息。 Kiama 支持Rewriting,请参阅RewriterTests 中的示例,并观看此video。这里有一个让你胃口大开的 sn-p:

        // 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 steps outside 的类型系统可以实现这一点。

        【讨论】:

        • 对于那些寻找提交的人。这里是:github.com/soundrabbit/scala/commit/…(我认为..)
        • 嘿,镜头在哪里?
        • 我刚刚遇到了这个问题,@zip 的想法听起来真的很棒,也许它甚至应该到目前为止所有案例类都有它?为什么不执行此操作?镜头很好,但有大量的类/案例类,如果你只想要一个二传手而没有像增量器这样花哨的东西,它只是样板。
        猜你喜欢
        • 1970-01-01
        • 2016-06-06
        • 2018-05-05
        • 2020-07-06
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多