【问题标题】:Can immutable be a memory hog?不可变可以成为内存猪吗?
【发布时间】:2010-03-26 23:18:51
【问题描述】:

假设我们有一个像Image 这样的内存密集型类,具有像Resize()ConvertTo() 这样的可链接方法。

如果这个类是不可变的,那么当我开始做i.Resize(500, 800).Rotate(90).ConvertTo(Gif) 之类的事情时,与修改自身的可变类相比,它会不会占用大量内存?如何用函数式语言处理这种情况?

【问题讨论】:

    标签: memory functional-programming immutability


    【解决方案1】:

    如果这个类是不可变的,会不会占用大量内存?

    通常您对单个对象的内存需求可能会加倍,因为您可能同时拥有一个“旧副本”和一个“新副本”。因此,您可以在程序的整个生命周期中将这种现象视为分配了一个比典型命令式程序中更多的对象。 (没有被“处理”的对象只是坐在那里,与任何其他语言具有相同的内存要求。)

    如何用函数式语言处理这种情况?

    什么都不做。或者更准确地说,分配健康的新对象。 如果您使用的是为函数式编程设计的实现,分配器和垃圾收集器几乎肯定会针对高分配率进行调整,一切都会好起来的。如果您不幸尝试在 JVM 上运行函数式代码,那么性能不会像定制实现那样好,但对于大多数程序来说还是没问题的。


    你能提供更多细节吗?

    当然。我将举一个非常简单的例子:1000x1000 灰度图像,每像素 8 位,旋转 180 度。以下是我们所知道的:

    • 在内存中表示图像需要 1MB。

    • 如果图像是可变的,则可以通过就地更新来旋转 180 度。所需的临时空间量足以容纳一个像素。你写了一个双重嵌套循环,相当于

      for (i in columns) do
        for (j in first half of rows) do {
           pixel temp := a[i, j]; 
           a[i, j] := a[width-i, height-j]; 
           a[width-i, height-j] := tmp
        }
      
    • 如果图像是不可变的,则需要创建一个全新的图像,并且暂时您必须挂在旧图像上。代码是这样的:

      new_a = Image.tabulate (width, height) (\ x y -> a[width-x, height-y])
      

      tabulate 函数分配一个完整的、不可变的二维数组并初始化其内容。在此操作期间,旧图像暂时占用内存。但是当tabulate 完成时,旧图像a 应该不再被使用,它的内存现在是空闲的(也就是说,有资格被垃圾收集器回收)。因此,所需的临时空间量足以容纳一张图像。

    • 在旋转过程中,不需要有其他类的对象的副本; 需要临时空间用于正在旋转的图像。

    注意对于其他操作,例如将(非方形)图像重新缩放或旋转 90 度,很可能即使图像是可变的,也很有可能需要整个图像的临时副本,因为尺寸会发生变化。另一方面,颜色空间转换和其他逐像素计算的计算可以通过使用非常小的临时空间的突变来完成。

    【讨论】:

    • 我认为即使是具有可变状态的对象在对图像进行大多数实质性转换时也需要源缓冲区和目标缓冲区,尤其是像 Resize()Rotate() 这样的东西。
    • 对不起,我不确定我是否理解旧文案,新文案思维。对于可链接方法返回的每个类,它是一个更大的对象,还是单个对象(在本例中为“i”)只是一个额外的对象。如果您能详细说明,不胜感激。
    • 如果没有完整的临时图像,您将如何进行色彩空间转换?
    • @gabe:现代垃圾收集器和内存分配器非常支持缓存。新对象被分配在一个称为 nursery 的区域中,该区域始终位于同一位置。只有在次要集合中幸存下来的对象才会被复制,因此不需要“大量复制”。托儿所总是在同一个位置,并且是连续的,而不是零散的,所以分配非常快。这些都不是火箭科学。这种收集器从 1995 年开始部署,并从 2000 年开始广泛使用。一些 JVM 迟到了,但解决方案是众所周知的。
    • 我的观点完全正确——图像处理是可变性可以产生重大影响的典型示例。
    【解决方案2】:

    是的。不变性是计算中永恒时空权衡的一个组成部分:您牺牲内存以换取通过上述锁和其他并发访问控制措施获得的并行处理速度的提高。

    函数式语言通常通过将它们分成非常细的粒度来处理这种性质的操作。您的 Image 类实际上并不保存图像的逻辑数据位;相反,它使用指向包含图像数据的更小的不可变数据段的指针或引用。当需要对图像数据执行操作时,将克隆和变异较小的片段,并返回带有更新引用的图像的新副本——其中大部分指向尚未复制或更改且保持完整的数据.

    这就是为什么函数式设计需要与命令式设计不同的基本思维过程的原因之一。不仅算法本身的布局非常不同,而且数据存储和结构也需要以不同的方式布局,以解决复制的内存开销。

    【讨论】:

      【解决方案3】:

      在某些情况下,不变性会迫使您克隆对象并需要分配更多内存。它不需要占用内存,因为旧的副本可以被丢弃。例如,CLR 垃圾收集器可以很好地处理这种情况,所以这(通常)不是什么大问题。

      但是,操作链实际上并不意味着克隆对象。功能列表当然就是这种情况。当您以典型方式使用它们时,您只需为单个元素分配一个内存单元(当将元素附加到列表的前面时)。

      您的图像处理示例也可以以更有效的方式实现。我将使用 C# 语法来使代码易于理解,而无需了解任何 FP(但在通常的函数式语言中看起来会更好)。您可以只存储要对图像执行的操作,而不是实际克隆图像。例如这样的:

      class Image { 
        Bitmap source;
        FileFormat format;
        float newWidth, newHeight;
        float rotation;
      
        // Public constructor to load the image from a file
        public Image(string sourceFile) { 
          this.source = Bitmap.FromFile(sourceFile); 
          this.newWidth = this.source.Width;
          this.newHeight = this.source.Height;
        }
      
        // Private constructor used by the 'cloning' methods
        private Image(Bitmap s, float w, float h, float r, FileFormat fmt) {
          source = s; newWidth = w; newHeight = h; 
          rotation = r; format = fmt;
        }
      
        // Methods that can be used for creating modified clones of
        // the 'Image' value using method chaining - these methods only
        // store operations that we need to do later
        public Image Rotate(float r) {
          return new Image(source, newWidth, newHeight, rotation + r, format);
        }
        public Image Resize(float w, float h) {
          return new Image(source, w, h, rotation, format);
        }
        public Image ConvertTo(FileFormat fmt) {
          return new Image(source, newWidth, newHeight, rotation, fmt);
        }
      
        public void SaveFile(string f) { 
          // process all the operations here and save the image
        }
      }
      

      每次调用方法时,该类实际上不会创建整个位图的克隆。当您最终尝试保存图像时,它只会跟踪稍后需要完成的操作。在以下示例中,底层 Bitmap 将仅创建一次:

       var i = new Image("file.jpg");
       i.Resize(500, 800).Rotate(90).ConvertTo(Gif).SaveFile("fileNew.gif");
      

      总之,代码看起来像是在克隆对象,而实际上每次调用某个操作时,您都在创建Image 类的新副本。但是,这并不意味着该操作占用大量内存 - 这可以隐藏在函数库中,可以通过各种方式实现(但仍保留重要的引用透明度)。

      【讨论】:

        【解决方案4】:

        这取决于所使用的数据结构的类型,以及它们在给定程序中的应用。一般来说,不变性在内存上不必过于昂贵。

        您可能已经注意到,函数式程序中使用的持久数据结构倾向于避开数组。这是因为持久性数据结构在“修改”时通常会重用它们的大部分组件。 (当然,它们并没有真正修改。返回了一个新的数据结构,但旧的数据结构和原来的一样。)See this picture 了解结构共享是如何工作的。一般来说,树结构是受欢迎的,因为可以从旧的不可变树中创建新的不可变树,只需重写从根到相关节点的路径。其他一切都可以重复使用,从而使该过程在时间和内存上都有效。

        关于您的示例,除了复制整个海量数组之外,还有几种方法可以解决该问题。 (这实际上效率非常低。)我首选的解决方案是使用数组块树来表示图像,从而允许在更新时进行相对较少的复制。请注意另一个优势:我们可以以相对较低的成本存储我们数据的多个版本。

        我并不是要争辩说,不变性总是无处不在——毕竟,函数式编程的真理和正义应该与实用主义相结合。

        【讨论】:

          【解决方案5】:

          是的,使用不可变对象的一个​​缺点是它们倾向于占用内存,我想到的一件事类似于惰性评估,即在请求新副本时提供参考,而当用户这样做时然后进行一些更改初始化对象的新副本。

          【讨论】:

          • Tcl 在内部做了类似的事情,除了它不会复制对象,如果只持有一个对它的引用。在这种情况下,直接更新对象是安全的,因为唯一可以看到差异的是期望值更改的调用者。
          【解决方案6】:

          简短而切题的答案:在我熟悉的 FP 语言(scala、erlang、clojure、F#)中,对于常见的数据结构:数组、列表、向量、元组,您需要了解浅/深副本和如何实施:

          例如

          Scala,clone() 对象与复制构造函数

          Does Scala AnyRef.clone perform a shallow or deep copy?

          Erlang:传递浅拷贝数据结构的消息可能会炸毁进程:

          http://groups.google.com/group/erlang-programming/msg/bb39d1a147f72800

          【讨论】:

          • 不回答问题。你的意思是不可变库做浅拷贝吗?
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2016-04-26
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多