Подтвердить что ты не робот

Scala: сбор обновлений/изменений неизменяемого состояния

В настоящее время я пытаюсь применить более функциональный стиль программирования к проекту с использованием низкоуровневого (основанного на LWJGL) графического интерфейса. Очевидно, что в таком случае необходимо переносить много состояний, которые изменяются в текущей версии. Моя цель состоит в том, чтобы в конечном итоге иметь совершенно неизменное состояние, чтобы избежать изменений состояния в качестве побочного эффекта. Я некоторое время изучал линзы сказаза и монады монахов, но моя главная проблема остается: все эти методы основаны на копировании на запись. Поскольку мое состояние имеет как большое количество полей, так и некоторые поля значительного размера, я беспокоюсь о производительности.

Насколько я знаю, наиболее распространенным подходом к модификации неизменяемых объектов является использование сгенерированного метода 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 каким-то образом?

Edit:

Мое предположение о том, что copy выполняет глубокую копию, действительно было неправильным.

4b9b3361

Ответ 1

Это в основном то же самое, что и представления, и это тип ленивой оценки; этот тип стратегии более или менее установлен по умолчанию в 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

Просто вызывает обычный конструктор. Нет клонирования карты.

Итак, когда вы копируете, вы создаете ровно один объект - новую копию того, что вы копируете, с заполненными полями. Если у вас большое количество полей, ваше представление будет быстрее (так как вам нужно создать один новый объект (два, если вы используете версию приложения-функции, так как вам также нужно создать объект функции), но у него есть только одно поле). В противном случае это должно быть примерно то же самое.

Итак, да, хорошая идея потенциально, но тщательно проверяйте, чтобы убедиться, что это того стоит в вашем случае - вам нужно написать справедливый бит кода вручную, а не позволить классу case сделать все это для вас.

Ответ 2

Я попытался написать (довольно грубый) тест для характеристик времени в вашей работе класса case 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.10 ГГц
  • ОЗУ: 8 ГБ памяти
  • ОС: 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)

Возможно, у вас разные оценки производительности.

Или может быть, что ваши профилированные времена фактически потрачены на создание Map и Array вместо копирования case class?