Scala:收集不可变状态的更新/更改

时间:2012-11-06 22:02:53

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

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

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

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

通过基准测试以及查看-Xprof的输出,看起来更新someState.copy(innocentField = 42)实际上执行了深层复制,当我增加largeMap的大小时,我发现性能显着下降和largeArray。我某种程度上期望新构造的实例共享原始状态的对象引用,因为内部引用应该只传递给构造函数。我可以以某种方式强制或禁用默认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执行深层复制确实是错误的。

2 个答案:

答案 0 :(得分:12)

这与视图基本相同,是一种惰性评估;这种类型的策略或多或少是Haskell中的默认策略,并且在Scala中使用了相当一部分(参见例如地图上的mapValues,分组集合,几乎任何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

只需调用常规构造函数即可。没有克隆地图。

因此,当您复制时,您只创建一个对象 - 您正在复制的内容的新副本,并填写字段。如果您有大量字段,您的视图将更快(因为您必须创建一个新对象(如果使用函数应用程序版本,则为两个,因为您还需要创建函数对象),但它只有一个字段)。否则它应该大致相同。

所以,是的,可能是个好主意,但仔细考虑以确保它在你的情况下是值得的 - 你必须亲自编写一些代码,而不是让案例类为你做这一切。

答案 1 :(得分:3)

我尝试为你的案例类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
  • RAM:8GB ram
  • OS:win7
  • Java:1.7
  • Scala: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