在函数式编程中,由于几乎所有数据结构都是不可变的,因此当必须更改状态时,会创建一个新的结构。这是否意味着更多的内存使用量?我非常了解面向对象的编程范例,现在我正在尝试学习功能编程范例。一切不变的概念使我感到困惑。与使用可变结构的程序相比,使用不可变结构的程序似乎需要更多的内存。我什至以正确的方式看着这个吗?
在函数式编程中,由于几乎所有数据结构都是不可变的,因此当必须更改状态时,会创建一个新的结构。这是否意味着更多的内存使用量?我非常了解面向对象的编程范例,现在我正在尝试学习功能编程范例。一切不变的概念使我感到困惑。与使用可变结构的程序相比,使用不可变结构的程序似乎需要更多的内存。我什至以正确的方式看着这个吗?
Answers:
在函数式编程中,由于几乎所有数据结构都是不可变的,因此当必须更改状态时,会创建一个新的结构。这是否意味着更多的内存使用量?
这取决于数据结构,执行的确切更改以及某些情况下的优化程序。作为一个示例,让我们考虑在列表之前:
list2 = prepend(42, list1) // list2 is now a list that contains 42 followed
// by the elements of list1. list1 is unchanged
在这里,额外的内存需求是恒定的-调用的运行时成本也是如此prepend
。为什么?因为prepend
只需创建一个新的单元格,该单元格42
的头和list1
尾即可。它无需复制或以其他方式迭代list2
即可实现此目的。也就是说,除了要存储所需的内存外42
,还list2
重用了所使用的相同内存list1
。由于两个列表都是不可变的,因此这种共享是绝对安全的。
同样,在使用平衡的树结构时,大多数操作仅需要对数数量的额外空间,因为树的所有内容(但只有一条路径)可以共享。
对于数组,情况有些不同。这就是为什么在许多FP语言中,数组不常用的原因。但是,如果您执行类似的操作arr2 = map(f, arr1)
并且arr1
在此行之后不再使用它,那么智能优化器实际上可以创建可变的代码,arr1
而不是创建新的数组(而不会影响程序的行为)。在那种情况下,表演当然会成为命令式语言。
天真的实现确实会暴露此问题-当您创建一个新的数据结构而不是就地更新现有数据结构时,您将不得不承担一些开销。
不同的语言有不同的处理方式,大多数使用一些技巧。
一种策略是垃圾收集。创建新结构的那一刻或不久之后,对旧结构的引用就超出了范围,垃圾回收器将立即或足够快地将其拾取,具体取决于GC算法。这意味着尽管仍有开销,但它只是暂时的,不会随数据量线性增长。
另一个正在选择不同种类的数据结构。数组是命令式语言(通常用某种动态重新分配容器(例如std::vector
C ++)包装)中的常用列表数据结构,而功能性语言通常更喜欢链接列表。通过链接列表,前置操作('cons')可以将现有列表重用作为新列表的尾部,因此真正分配的只是新列表的头。对于其他类型的数据结构也存在类似的策略-集,树(随便命名)。
然后是懒惰的评估,Haskell。这个想法是,您创建的数据结构不会立即完全创建。而是将它们存储为“ thunk”(您可以将它们视为在需要时构造值的配方)。仅当需要该值时,该thunk才会扩展为实际值。这意味着可以将内存分配推迟到需要评估之前,此时,可以在一个内存分配中组合多个thunk。
我只对Clojure及其不可变数据结构有所了解。
Clojure提供了一组不可变的列表,向量,集合和映射。由于不能更改它们,因此从不可变集合中“添加”或“删除”某些内容意味着要像旧集合一样创建新集合,但需要进行更改。持久性是一个术语,用于描述属性,其中在“更改”之后,集合的旧版本仍然可用,并且集合对大多数操作保持其性能保证。具体来说,这意味着无法使用完整副本创建新版本,因为这需要线性时间。不可避免地,使用链接的数据结构来实现持久性集合,以便新版本可以与先前版本共享结构。
在图形上,我们可以表示以下内容:
(def my-list '(1 2 3))
+---+ +---+ +---+
| 1 | ---> | 2 | ---> | 3 |
+---+ +---+ +---+
(def new-list (conj my-list 0))
+-----------------------------+
+---+ | +---+ +---+ +---+ |
| 0 | --->| | 1 | ---> | 2 | ---> | 3 | |
+---+ | +---+ +---+ +---+ |
+-----------------------------+