【问题标题】:Scala: collecting updates/changes of immutable stateScala:收集不可变状态的更新/更改
【发布时间】:2012-10-26 22:40:34
【问题描述】:

我目前正在尝试将更具功能性的编程风格应用于涉及低级(基于 LWJGL)GUI 开发的项目。显然,在这种情况下,需要携带很多状态,这在当前版本中是可变的。我的目标是最终拥有一个完全不可变的状态,以避免状态更改作为副作用。我研究了 scalaz 的镜头和状态单子一段时间,但我主要关心的是:所有这些技术都依赖于写时复制。由于我所在的州既有大量的字段,也有一些相当大的字段,所以我担心性能。

据我所知,修改不可变对象的最常用方法是使用 case class 生成的 copy 方法(这也是镜头在引擎盖下所做的)。我的第一个问题是,这个copy 方法实际上是如何实现的?我对一个类进行了一些实验,例如:

case class State(
  innocentField: Int, 
  largeMap: Map[Int, Int], 
  largeArray: Array[Int]
)

通过基准测试以及查看-Xprof 的输出,看起来更新someState.copy(innocentField = 42) 实际上执行了深层复制,当我增加largeMaplargeArray 的大小时,我观察到性能显着下降。我以某种方式期望新构造的实例共享原始状态的对象引用,因为在内部引用应该只是传递给构造函数。我可以以某种方式强制或禁用默认 copy 的这种深层复制行为吗?

在思考写时复制问题时,我想知道在 FP 中是否有更通用的解决方案来解决这个问题,它以一种增量方式存储不可变数据的更改(在“收集更新”的意义上或“收集变化”)。令我惊讶的是,我找不到任何东西,所以我尝试了以下方法:

// example state with just two fields
trait State {
  def getName: String
  def getX: Int

  def setName(updated: String): State = new CachedState(this) {
    override def getName: String = updated
  }
  def setX(updated: Int): State = new CachedState(this) {
    override def getX: Int = updated
  }

  // convenient modifiers
  def modName(f: String => String) = setName(f(getName))
  def modX(f: Int => Int) = setX(f(getX))

  def build(): State = new BasicState(getName, getX)
}

// actual (full) implementation of State
class BasicState(
  val getName: String, 
  val getX: Int
) extends State


// CachedState delegates all getters to another state
class CachedState(oldState: State) extends State {
  def getName = oldState.getName
  def getX    = oldState.getX
}

现在这允许做这样的事情:

var s: State = new BasicState("hello", 42)

// updating single fields does not copy
s = s.setName("world")
s = s.setX(0)

// after a certain number of "wrappings"
// we can extract (i.e. copy) a normal instance
val ns = s.setName("ok").setX(40).modX(_ + 2).build()

我现在的问题是:你觉得这个设计怎么样?这是我不知道的某种 FP 设计模式(除了与 Builder 模式的相似性)吗?由于我没有发现任何类似的东西,我想知道这种方法是否存在一些重大问题?或者有没有更标准的方法来解决写时复制的瓶颈而不放弃不变性?

是否有可能以某种方式统一 get/set/mod 功能?

编辑:

我认为copy 执行深拷贝的假设确实是错误的。

【问题讨论】:

  • 这是一个非常好的问题,最重要的是,它写得很好,值得注意的是,OP 花了一些心思。我真的想知道谁投票结束了这个......
  • 回想起来,最好问两个问题,一个是关于案例类副本的基准测试结果,另一个是关于提议设计的改进。但显然我没想到我搞砸了基准:)。
  • 我想说这个问题在你的情况下已经解决了,因为你不必再担心复制案例类了。关于您的设计改进:对我来说,跟踪状态的标准 FP 解决方案是 State Monad,网络上有很多材料,例如 this one。您的解决方案的主要问题是它将不可变的数据更改存储在 可变结构 中,这不是纯粹的功能设计。状态单子以纯粹的方式为您提供相同的功能。

标签: scala design-patterns functional-programming state immutability


【解决方案1】:

这与视图基本相同,是一种惰性求值;这种类型的策略或多或少是 Haskell 中的默认策略,并且在 Scala 中使用得相当多(参见例如 mapValues on maps、grouped on collections、Iterator 或 Stream 上几乎所有返回另一个 Iterator 或 Stream 的东西等)。这是一种行之有效的策略,可以避免在正确的环境中进行额外的工作。

但我认为你的前提有些错误。

case class Foo(bar: Int, baz: Map[String,Boolean]) {}
Foo(1,Map("fish"->true)).copy(bar = 2)

实际上不会导致地图被深度复制。它只是设置参考。字节码证明:

62: astore_1
63: iconst_2   // This is bar = 2
64: istore_2
65: aload_1
66: invokevirtual   #72; //Method Foo.copy$default$2:()Lscala/collection/immutable/Map;
69: astore_3   // That was baz
70: aload_1
71: iload_2
72: aload_3
73: invokevirtual   #76; //Method Foo.copy:(ILscala/collection/immutable/Map;)LFoo;

让我们看看copy$default$2 的作用:

0:  aload_0
1:  invokevirtual   #50; //Method baz:()Lscala/collection/immutable/Map;
4:  areturn

只返回地图。

copy 本身呢?

0:  new #2; //class Foo
3:  dup
4:  iload_1
5:  aload_2
6:  invokespecial   #44; //Method "<init>":(ILscala/collection/immutable/Map;)V
9:  areturn

只需调用常规构造函数。不克隆地图。

因此,当您复制时,您只创建了一个对象 - 您正在复制的内容的一个新副本,其中填写了字段。如果您有大量字段,您的视图会更快(因为您必须创建一个新对象(如果您使用功能应用程序版本,则为两个,因为您还需要创建功能对象)但它只有一个字段)。否则应该差不多。

所以,是的,这可能是个好主意,但请仔细进行基准测试,以确保它在您的案例中是值得的——您必须手动编写相当多的代码,而不是让案例类为您完成所有工作。

【讨论】:

  • 我显然不能反对字节码中的证明:)。但是后来我完全感到困惑,因为如果案例类的(未更改的)字段之一是一个巨大的 Array[Int] 并且我还可以看到 java.lang.System.arraycopy 在-Xprof 的输出。必须进一步检查...
  • 尝试将数组包装在您无法克隆的东西中,例如class Wrapper[A](val wrapped: A) {} 并查看问题是否消失或让您确定代码中需要访问数组本身的位置并正在创建副本。
【解决方案2】:

我尝试为您的案例类copy 操作编写一个(相当粗略的)计时性能测试。

object CopyCase {

    def main(args: Array[String]) = {

        val testSizeLog = byTen(10 #:: Stream[Int]()).take(6).toList
        val testSizeLin = (100 until 1000 by 100) ++ (1000 until 10000 by 1000) ++ (10000 to 40000 by 10000)

        //warmUp
        runTest(testSizeLin)
        //test with logarithmic size increments 
        val times = runTest(testSizeLog)
        //test with linear size increments 
        val timesLin = runTest(testSizeLin)

        times.foreach(println)
        timesLin.foreach(println)
    }

    //The case class to test for copy
    case class State(
        innocentField: Int, 
        largeMap: Map[Int, Int], 
        largeArray: Array[Int]
    )

    //executes the test
    def runTest(sizes: Seq[Int]) = 
        for {
            s <- sizes
            st = State(s, largeMap(s), largeArray(s))
            //(time, state) = takeTime (st.copy(innocentField = 42)) //single run for each size
            (time, state) = mean(st.copy(innocentField = 42))(takeTime) //mean time on multiple runs for each size
        } yield (s, time)

    //Creates the stream of 10^n  with n = Naturals+{0}
    def byTen(s: Stream[Int]): Stream[Int] = s.head #:: byTen(s map (_ * 10))

    //append the execution time to the result
    def takeTime[A](thunk: => A): (Double, A) = {
        import System.{currentTimeMillis => millis, nanoTime => nanos}
        val t0:Double = nanos
        val res = thunk
        val time = ((nanos - t0) / 1000)
        (time, res)
    }

    //does a mean on multiple runs of the first element of the pair 
    def mean[A](thunk: => A)(fun: (=> A) => (Double, A)) = {
        val population = 50
        val mean = ((for (n <- 1 to population) yield fun(thunk)) map (_._1) ).sum / population
        (mean, fun(thunk)._2)
    }

    //Build collections for the requested size
    def largeMap(size: Int) = (for (i <- (1 to size)) yield (i, i)).toMap
    def largeArray(size: Int) = Array.fill(size)(1)
}

在这台机器上:

  • CPU:64 位双核 i5 3.10GHz
  • 内存:8GB 内存
  • 操作系统:win7
  • Java:1.7
  • 斯卡拉:2.9.2

我有以下结果,对我来说这看起来很正常。

(size, millisecs to copy)
(10,0.4347000000000001)
(100,0.4412600000000001)
(1000,0.3953200000000001)
(10000,0.42161999999999994)
(100000,0.4478600000000002)
(1000000,0.42816000000000015)
(100,0.4084399999999999)
(200,0.41494000000000014)
(300,0.42156000000000016)
(400,0.4281799999999999)
(500,0.42160000000000003)
(600,0.4347200000000001)
(700,0.43466000000000016)
(800,0.41498000000000007)
(900,0.40178000000000014)
(1000,0.44134000000000007)
(2000,0.42151999999999995)
(3000,0.42148)
(4000,0.40842)
(5000,0.38860000000000006)
(6000,0.4413600000000001)
(7000,0.4743200000000002)
(8000,0.44795999999999997)
(9000,0.45448000000000005)
(10000,0.45448)
(20000,0.4281600000000001)
(30000,0.46768)
(40000,0.4676200000000001)

也许您有不同的性能衡量标准。

或者您的分析时间实际上是用于生成MapArray,而不是复制case class

【讨论】:

    猜你喜欢
    • 2018-12-18
    • 1970-01-01
    • 1970-01-01
    • 2016-11-10
    • 2019-01-10
    • 1970-01-01
    • 2021-09-27
    • 2016-10-02
    • 1970-01-01
    相关资源
    最近更新 更多