假设我们有一个像Image
这样的内存密集型类,可以使用Resize()
和ConvertTo()
这样的可链接方法。
如果这个类是不可变的,那么当我开始做像i.Resize(500, 800).Rotate(90).ConvertTo(Gif)
这样的事情时,它会不会占用大量的内存,相比之下,一个可变的自我修改?如何用函数式语言处理这种情况?
答案 0 :(得分:24)
如果这个类是不可变的,那么它会占用大量的内存吗?
通常,您对单个对象的内存要求可能会翻倍,因为您可能会同时拥有“旧副本”和“新副本”。因此,您可以在程序的生命周期内查看此现象,因为分配了一个大对象,而不是典型的命令式程序。 (没有“工作”的对象只是坐在那里,具有与任何其他语言相同的内存要求。)
如何用函数式语言处理这种情况?
绝对不做任何事。或者更确切地说,分配健康状况良好的新对象。 如果您正在使用为函数式编程设计的实现,那么分配器和垃圾收集器几乎肯定会针对高分配率进行调整,一切都会好的。如果您不幸尝试在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
函数分配整个不可变的2D数组并初始化其内容。在此操作期间,旧映像暂时占用内存。但是当tabulate
完成时,不应再使用旧图像a
,并且其内存现在是免费的(也就是说,有资格通过垃圾收集器进行回收)。因此,所需的临时空间量足以容纳一张图像。
在轮换进行的同时,不需要拥有其他类的对象副本;
N.B。对于其他操作,例如将(非方形)图像重新缩放或旋转90度,很可能即使图像可变,也需要整个图像的临时副本,因为尺寸会发生变化。另一方面,可以使用具有非常小的临时空间的变异来逐个像素地完成颜色空间变换和其他计算。
答案 1 :(得分:11)
是。不可变性是计算中永恒时空权衡的一个组成部分:你牺牲内存来换取上述锁和其他并发访问控制措施所带来的并行性提高的处理速度。
功能语言通常通过将它们分成非常精细的颗粒来处理这种性质的操作。您的Image类实际上并不保存图像的逻辑数据位;相反,它使用指针或引用包含图像数据的更小的不可变数据段。当需要对图像数据执行操作时,克隆和变异较小的段,并返回带有更新引用的图像的新副本 - 其中大多数指向尚未复制或更改且保持不变的数据
这就是为什么功能设计需要与命令式设计不同的基本思维过程的原因之一。算法本身不仅布局不同,而且数据存储和结构也需要以不同的方式布局,以解决复制的内存开销。
答案 2 :(得分: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
类的新副本。但是,这并不意味着操作内存昂贵 - 这可以隐藏在功能库中,可以通过各种方式实现(但仍保留重要的引用透明度)。 / p>
答案 3 :(得分:1)
这取决于所使用的数据结构的类型,它们在给定程序中的应用。一般来说,不可变性在记忆中不必过于昂贵。
您可能已经注意到功能程序中使用的持久数据结构往往会避开数组。这是因为持久数据结构通常在“修改”时重用其大部分组件。 (当然,它们并没有真正被修改过。返回一个新的数据结构,但旧的数据结构与它一样。)See this picture以了解结构共享如何工作。通常,树结构是有利的,因为可以从旧的不可变树中创建新的不可变树,仅重写从根到所讨论的节点的路径。其他所有东西都可以重复使用,使得这个过程在时间和记忆上都很有效。
关于您的示例,除了复制整个海量数组之外,还有几种方法可以解决问题。 (这实际上是非常低效的。)我首选的解决方案是使用数组树的树来表示图像,允许相对较少的更新复制。请注意另一个优点:我们可以以相对较小的成本存储我们数据的多个版本。
我并不是说不可变性总是在任何地方都是答案 - 毕竟,函数式编程的真理和正义应该用实用主义来缓和。
答案 4 :(得分:0)
是的,使用不可变对象的一个缺点是它们往往会占用内存。我想到的一件事就是类似于懒惰评估,这是当请求新副本提供引用时以及当用户执行时一些更改然后初始化对象的新副本。
答案 5 :(得分:-1)
简短,切实的答案:在FP语言中我熟悉(scala,erlang,clojure,F#),对于通常的数据结构:数组,列表,向量,元组,你需要理解浅/深拷贝和如何实施:
e.g。
Scala,clone()对象与复制构造函数
Does Scala AnyRef.clone perform a shallow or deep copy?
Erlang:传递浅层复制数据结构的消息可能会破坏进程:
http://groups.google.com/group/erlang-programming/msg/bb39d1a147f72800