函数式编程 - 不变性昂贵吗? [关闭]
Posted
技术标签:
【中文标题】函数式编程 - 不变性昂贵吗? [关闭]【英文标题】:Functional programming - is immutability expensive? [closed] 【发布时间】:2011-05-05 08:30:20 【问题描述】:问题分为两部分。第一个是概念性的。接下来在 Scala 中更具体地研究同一个问题。
-
在编程语言中仅使用不可变数据结构是否会导致在实践中实现某些算法/逻辑本质上在计算上更加昂贵?这说明了不变性是纯函数式语言的核心原则。还有其他因素会影响这一点吗?
让我们举一个更具体的例子。 Quicksort 通常是使用内存数据结构上的可变操作来教授和实现的。如何以与可变版本相当的计算和存储开销以纯功能方式实现这样的事情。特别是在 Scala 中。我在下面列出了一些粗略的基准。
更多详情:
我来自命令式编程背景(C++、Java)。我一直在探索函数式编程,特别是 Scala。
纯函数式编程的一些主要原则:
-
函数是一等公民。
函数没有副作用,因此对象/数据结构为immutable。
尽管现代JVMs 在创建对象方面非常高效,而garbage collection 对于短期对象来说非常便宜,但最好还是尽量减少对象创建吧?至少在并发和锁定不是问题的单线程应用程序中。由于 Scala 是一种混合范式,因此可以在必要时选择使用可变对象编写命令式代码。但是,作为一个花了很多年时间尝试重用对象并最小化分配的人。我想很好地了解甚至不允许这样做的思想流派。
作为一个具体案例,this tutorial6 中的这段代码 sn-p 让我有些惊讶。它有一个 Java 版本的 Quicksort,然后是一个整洁的 Scala 实现。
这是我对实现进行基准测试的尝试。我没有做过详细的分析。但是,我的猜测是 Scala 版本更慢,因为分配的对象数量是线性的(每个递归调用一个)。尾调用优化有没有可能发挥作用?如果我是对的,Scala 支持自递归调用的尾调用优化。所以,它应该只是帮助它。我正在使用 Scala 2.8。
Java 版本
public class QuickSortJ
public static void sort(int[] xs)
sort(xs, 0, xs.length -1 );
static void sort(int[] xs, int l, int r)
if (r >= l) return;
int pivot = xs[l];
int a = l; int b = r;
while (a <= b)
while (xs[a] <= pivot) a++;
while (xs[b] > pivot) b--;
if (a < b) swap(xs, a, b);
sort(xs, l, b);
sort(xs, a, r);
static void swap(int[] arr, int i, int j)
int t = arr[i]; arr[i] = arr[j]; arr[j] = t;
Scala 版本
object QuickSortS
def sort(xs: Array[Int]): Array[Int] =
if (xs.length <= 1) xs
else
val pivot = xs(xs.length / 2)
Array.concat(
sort(xs filter (pivot >)),
xs filter (pivot ==),
sort(xs filter (pivot <)))
比较实现的 Scala 代码
import java.util.Date
import scala.testing.Benchmark
class BenchSort(sortfn: (Array[Int]) => Unit, name:String) extends Benchmark
val ints = new Array[Int](100000);
override def prefix = name
override def setUp =
val ran = new java.util.Random(5);
for (i <- 0 to ints.length - 1)
ints(i) = ran.nextInt();
override def run = sortfn(ints)
val benchImmut = new BenchSort( QuickSortS.sort , "Immutable/Functional/Scala" )
val benchMut = new BenchSort( QuickSortJ.sort , "Mutable/Imperative/Java " )
benchImmut.main( Array("5"))
benchMut.main( Array("5"))
结果
连续五次运行的时间(以毫秒为单位)
Immutable/Functional/Scala 467 178 184 187 183
Mutable/Imperative/Java 51 14 12 12 12
【问题讨论】:
天真地实现或使用为命令式语言开发的方法会很昂贵。智能编译器(例如 GHC、Haskell 编译器 - 而 Haskell 只有 不可变值)可以利用不可变性并实现可以与使用可变性的代码相媲美的性能。不用说,快速排序的幼稚实现仍然很慢,因为它使用大量递归和O(n)
list concat 等代价高昂的东西。虽然它比伪代码版本短 ;)
一篇很棒的相关博客文章在这里:blogs.sun.com/jrose/entry/larval_objects_in_the_vm 鼓励他,因为这将使 Java 以及函数式 VM 语言受益匪浅
这个 SO 线程有很多关于函数式编程效率的详细讨论。 ***.com/questions/1990464/… 。解答了很多我想知道的问题。
这里最天真的事情是你的基准。你不能用这样的代码对任何东西进行基准测试!在得出任何结论之前,您应该认真阅读一些关于在 JVM 上进行基准测试的文章……您是否知道 JVM 在您运行代码之前可能还没有 JITted?您是否适当地设置了堆初始和最大大小(这样您就不会考虑 JVM 进程请求更多内存的时间?)?您是否知道正在编译或重新编译哪些方法?你知道GC吗?你从这段代码中得到的结果毫无意义!
@userunknown 不,这是声明性的。命令式编程“通过命令改变状态”,而函数式编程“是一种声明式编程范例”,“避免改变状态”(Wikipedia)。所以,是的,函数式和命令式是两个完全不同的东西,你写的代码不是命令式的。
【参考方案1】:
由于这里有一些误解,我想澄清一些观点。
“就地”快速排序并不是真正就地(根据定义,快速排序不是就地)。它需要以堆栈空间的形式为递归步骤提供额外的存储空间,最好的情况是 O(log n) 的顺序,但 O (n) 在最坏的情况下。
实现对数组进行操作的快速排序的功能变体违背了目的。数组永远不会不可变。
快速排序的“正确”功能实现使用不可变列表。它当然不是就地的,但它具有相同的最坏情况渐近运行时间 (O(n^2)) 和空间复杂度 (O em>(n)) 作为程序就地版本。
平均而言,它的运行时间仍然与就地变体(O(n log n))。然而,它的空间复杂度仍然是O(n)。
函数式快速排序实现有两个明显的缺点。下面,让我们考虑一下来自Haskell introduction 的 Haskell 中的这个参考实现(我不知道 Scala ......):
qsort [] = []
qsort (x:xs) = qsort lesser ++ [x] ++ qsort greater
where lesser = (filter (< x) xs)
greater = (filter (>= x) xs)
第一个缺点是枢轴元素的选择,非常不灵活。现代快速排序实现的力量在很大程度上依赖于对枢轴的明智选择(比较“Engineering a sort function” by Bentley et al.)。上述算法在这方面很差,大大降低了平均性能。
其次,该算法使用 列表连接(而不是列表构造),这是一个 O(n) 操作。这不会影响渐近复杂度,但它是一个可衡量的因素。
第三个缺点有些隐藏:与“就地”变体不同,此实现不断地从堆中请求内存以获取列表的 cons 单元,并可能将内存分散到各处。因此,该算法的缓存局部性非常很差。我不知道现代函数式编程语言中的智能分配器是否可以缓解这种情况——但在现代机器上,缓存未命中已成为主要的性能杀手。
结论是什么? 与其他人不同,我不会说快速排序本质上是必须的,这就是它在 FP 环境中表现不佳的原因。恰恰相反,我认为快速排序是函数式算法的完美示例:它无缝转换为不可变环境,其渐近运行时间和空间复杂度与过程实现相当,甚至其过程实现也采用递归。
但这个算法仍然在受限于不可变域时表现更差。这样做的原因是该算法具有从许多(有时是低级)微调中受益的特殊属性,这些微调只能在数组上有效执行。对快速排序的简单描述忽略了所有这些复杂性(在功能变体和程序变体中)。
阅读“设计排序函数”后,我不再认为快速排序是一种优雅的算法。有效实施,它是一个笨拙的烂摊子,是工程师的作品,而不是艺术家的作品(不要贬低工程!这有它自己的审美)。
但我还想指出,这一点特别适用于快速排序。并非每种算法都适合进行相同类型的低级调整。很多算法和数据结构确实可以在不可变的环境中表达而不会造成性能损失。
并且通过消除昂贵的副本或跨线程同步的需要,不变性甚至可以降低性能成本。
所以,回答最初的问题,“不变性很昂贵吗?”——在快速排序的特定情况下,确实存在不变性的结果。但总的来说,没有。
【讨论】:
+1 - 很好的答案!虽然我个人会以 sometimes 而不是 no 结尾。不过,这只是个性——你已经很好地解释了这些问题。 您应该补充一点,与命令式版本相比,使用不可变值的正确实现可以立即并行化。在现代技术背景下,这变得越来越重要。 使用qsort lesser ++ (x : qsort greater)
有多大帮助?【参考方案2】:
作为函数式编程的基准,这有很多问题。亮点包括:
您使用的原语可能需要装箱/拆箱。您不是在尝试测试包装原始对象的开销,而是在尝试测试不变性。 您选择了一种算法,其中就地操作异常有效(并且可以证明是有效的)。如果您想证明存在可变实现时更快的算法,那么这是一个不错的选择;否则,这可能会产生误导。 您使用了错误的计时功能。使用System.nanoTime
。
基准太短,您无法确信 JIT 编译不会占测量时间的重要部分。
数组未以有效方式拆分。
数组是可变的,因此将它们与 FP 一起使用无论如何都是一种奇怪的比较。
因此,此比较很好地说明了您必须详细了解您的语言(和算法)才能编写高性能代码。但这并不是 FP 与非 FP 的一个很好的比较。如果需要,请查看Haskell vs. C++ at the Computer Languages Benchmark Game。带回家的信息是,惩罚通常不超过 2 或 3 倍左右,但这真的取决于。 (没有保证 Haskell 的人已经编写了最快的算法,但至少他们中的一些人可能尝试过!再说一次,一些 Haskell 调用 C 库......)
现在,假设您确实想要一个更合理的快速排序基准,认识到这可能是 FP 与可变算法的最坏情况之一,并忽略数据结构问题(即假装我们可以拥有一个不可变数组):
object QSortExample
// Imperative mutable quicksort
def swap(xs: Array[String])(a: Int, b: Int)
val t = xs(a); xs(a) = xs(b); xs(b) = t
def muQSort(xs: Array[String])(l: Int = 0, r: Int = xs.length-1)
val pivot = xs((l+r)/2)
var a = l
var b = r
while (a <= b)
while (xs(a) < pivot) a += 1
while (xs(b) > pivot) b -= 1
if (a <= b)
swap(xs)(a,b)
a += 1
b -= 1
if (l<b) muQSort(xs)(l, b)
if (b<r) muQSort(xs)(a, r)
// Functional quicksort
def fpSort(xs: Array[String]): Array[String] =
if (xs.length <= 1) xs
else
val pivot = xs(xs.length/2)
val (small,big) = xs.partition(_ < pivot)
if (small.length == 0)
val (bigger,same) = big.partition(_ > pivot)
same ++ fpSort(bigger)
else fpSort(small) ++ fpSort(big)
// Utility function to repeat something n times
def repeat[A](n: Int, f: => A): A =
if (n <= 1) f else f; repeat(n-1,f)
// This runs the benchmark
def bench(n: Int, xs: Array[String], silent: Boolean = false)
// Utility to report how long something took
def ptime[A](f: => A) =
val t0 = System.nanoTime
val ans = f
if (!silent) printf("elapsed: %.3f sec\n",(System.nanoTime-t0)*1e-9)
ans
if (!silent) print("Scala builtin ")
ptime repeat(n,
val ys = xs.clone
ys.sorted
)
if (!silent) print("Mutable ")
ptime repeat(n,
val ys = xs.clone
muQSort(ys)()
ys
)
if (!silent) print("Immutable ")
ptime repeat(n,
fpSort(xs)
)
def main(args: Array[String])
val letters = (1 to 500000).map(_ => scala.util.Random.nextPrintableChar)
val unsorted = letters.grouped(5).map(_.mkString).toList.toArray
repeat(3,bench(1,unsorted,silent=true)) // Warmup
repeat(3,bench(10,unsorted)) // Actual benchmark
请注意对功能快速排序的修改,使其尽可能只对数据进行一次处理,并与内置排序进行比较。当我们运行它时,我们会得到类似的东西:
Scala builtin elapsed: 0.349 sec
Mutable elapsed: 0.445 sec
Immutable elapsed: 1.373 sec
Scala builtin elapsed: 0.343 sec
Mutable elapsed: 0.441 sec
Immutable elapsed: 1.374 sec
Scala builtin elapsed: 0.343 sec
Mutable elapsed: 0.442 sec
Immutable elapsed: 1.383 sec
因此,除了了解到尝试编写自己的排序是一个坏主意之外,我们发现如果对不可变快速排序进行了一些仔细的实施,那么对于不可变快速排序会有大约 3 倍的惩罚。 (您还可以编写一个 trisect 方法,返回三个数组:小于、等于和大于枢轴的数组。这可能会加快速度。)
【讨论】:
只是关于装箱/拆箱。如果有的话,这应该是对 java 方面的惩罚,对吧? Int 不是 Scala 的首选数字类型(与整数)。因此,scala 方面没有拳击比赛。装箱只是 java 端的一个问题,因为自动装箱从 scala Int 到 java.lang.Integer/int。这是一个详细讨论这个主题的链接ansorg-it.com/en/scalanews-001.html 是的,我在这里扮演魔鬼代言人。可变性是快速排序设计的一个组成部分。这就是为什么我对解决问题的纯函数方法非常好奇。叹息,我已经在线程上第 10 次说了这个声明:-)。当我醒来并回来时会看你的帖子的其余部分。谢谢。 @smartnut007 - Scala 集合是通用的。泛型在大多数情况下需要盒装类型(尽管正在努力将它们专门用于某些原始类型)。所以你不能使用所有漂亮的集合方法并假设当你通过它们传递原始类型的集合时不会受到惩罚。原始类型很可能必须在输入时装箱并在输出时拆箱。 我不喜欢你所说的最重要的缺陷只是猜测:-) @smartnut007 - 这是一个最严重的缺陷,因为它很难检查,如果是真的真的会搞砸结果。如果您确定没有拳击,那么我同意该缺陷无效。缺陷不在于有拳击,而是你不知道是否有拳击(我也不确定——专业化让这变得很棘手弄清楚)。在 Java 方面(或 Scala 可变实现)没有装箱,因为您只使用原语。无论如何,一个不可变的版本通过 n log n 空间工作,因此您最终会将比较/交换的成本与内存分配进行比较。【参考方案3】:我不认为 Scala 版本实际上是尾递归的,因为您使用的是Array.concat
。
另外,仅仅因为这是惯用的 Scala 代码,这并不意味着它是最好的方法。
最好的方法是使用 Scala 的内置排序函数之一。这样您就获得了不变性保证并知道您有一个快速的算法。
请参阅 Stack Overflow 问题 How do I sort an array in Scala? 以获取示例。
【讨论】:
另外,我认为不可能进行尾递归快速排序,因为您必须进行两次递归调用 有可能,您只需使用延续闭包将您的可能堆栈帧提升到堆上。 inbuilt scala.util.Sorting.quickSort(array) 改变数组。它和 java 一样快,这并不奇怪。我对高效的纯功能解决方案感兴趣。如果不是,为什么。这是Scala的限制还是一般的功能范式。那种东西。 @smartnut007:您使用的是哪个版本的 Scala?在 Scala 2.8 中,您可以执行array.sorted
,它返回一个新的排序数组,不会改变原始数组。
@AlexLo - 可以进行尾递归快速排序。比如:TAIL-RECURSIVE-QUICKSORT(Array A, int lo, int hi): while p < r: q = PARTITION(A, lo, hi); TAIL-RECURSIVE-QUICKSORT(A, lo, q - 1); p = q + 1;
【参考方案4】:
不变性并不昂贵。如果您测量程序必须执行的任务的一小部分,并根据启动的可变性选择一个解决方案(例如测量快速排序),那肯定会很昂贵。
简单地说,在使用纯函数式语言时,您不会快速排序。
让我们从另一个角度考虑这个问题。让我们考虑这两个函数:
// Version using mutable data structures
def tailFrom[T : ClassManifest](arr: Array[T], p: T => Boolean): Array[T] =
def posIndex(i: Int): Int =
if (i < arr.length)
if (p(arr(i)))
i
else
posIndex(i + 1)
else
-1
var index = posIndex(0)
if (index < 0) Array.empty
else
var result = new Array[T](arr.length - index)
Array.copy(arr, index, result, 0, arr.length - index)
result
// Immutable data structure:
def tailFrom[T](list: List[T], p: T => Boolean): List[T] =
def recurse(sublist: List[T]): List[T] =
if (sublist.isEmpty) sublist
else if (p(sublist.head)) sublist
else recurse(sublist.tail)
recurse(list)
基准测试,你会发现使用可变数据结构的代码性能要差得多,因为它需要复制数组,而不可变代码不需要自己关心。
当您使用不可变数据结构进行编程时,您可以构建代码以利用其优势。它不仅仅是数据类型,甚至是单个算法。该程序将以不同的方式设计。
这就是为什么基准测试通常毫无意义。要么选择对一种风格或另一种风格很自然的算法,然后那种风格获胜,要么你对整个应用程序进行基准测试,这通常是不切实际的。
【讨论】:
【参考方案5】:对数组进行排序是宇宙中最紧迫的任务。毫不奇怪,许多优雅的“不可变”策略/实现在“排序数组”微基准测试中表现不佳。不过,这并不意味着“一般”而言,不变性是昂贵的。在许多任务中,不可变实现的性能与可变实现相当,但数组排序通常不是其中之一。
【讨论】:
【参考方案6】:如果您只是将命令式算法和数据结构重写为函数式语言,那确实会很昂贵且无用。为了让事情大放异彩,您应该使用仅在函数式编程中可用的特性:数据结构持久性、惰性求值等。
【讨论】:
您能否提供一个在 Scala 中的实现。 powells.com/biblio/17-0521631246-0(Chris Okasaki 的纯函数式数据结构)- 看看这本书。在实现有效的算法和数据结构时,它有一个很好的故事来讲述如何利用函数式编程的优势。 code.google.com/p/pfds Debashish Ghosh 在 Scala 中实现的一些数据结构 您能解释一下为什么您认为 Scala 不是必须的吗?list.filter (foo).sort (bar).take (10)
- 还有什么比这更重要的?【参考方案7】:
Scala
中不变性的代价这是一个几乎与 Java 版本一样快的版本。 ;)
object QuickSortS
def sort(xs: Array[Int]): Array[Int] =
val res = new Array[Int](xs.size)
xs.copyToArray(res)
(new QuickSortJ).sort(res)
res
此版本制作数组的副本,使用 Java 版本对其进行就地排序并返回副本。 Scala 不会强制您在内部使用不可变结构。
因此,Scala 的好处是您可以根据需要利用可变性和不变性。缺点是如果你做错了,你就不会真正获得不变性的好处。
【讨论】:
虽然这不是问题的准确答案,但我认为它是任何好的答案的一部分:使用可变结构时快速排序更快。但是不变性的主要优点是接口,至少在 Scala 中你可以两者兼得。快速排序的可变性更快,但这并不妨碍您编写高性能的、主要是不可变的代码。【参考方案8】:众所周知,QuickSort 在原地完成时速度更快,因此这不是一个公平的比较!
话虽如此... Array.concat? 如果不出意外,您将展示为命令式编程优化的集合类型在您尝试在函数式算法中使用时如何特别慢;几乎任何其他选择都会更快!
另一个非常需要考虑的重点,也许比较这两种方法时最重要的问题是:“这对多节点/内核的扩展效果如何?”
很有可能,如果您正在寻找不可变的快速排序,那么您这样做是因为您实际上想要一个并行快速排序。***对此主题有一些引用:http://en.wikipedia.org/wiki/Quicksort#Parallelizations
scala 版本可以在函数递归之前简单地分叉,如果您有足够的可用内核,它可以非常快速地对包含数十亿条目的列表进行排序。
现在,如果我可以在其上运行 Scala 代码,我系统中的 GPU 有 128 个内核可供我使用,而这是在比当前一代落后两年的简单桌面系统上。
这将如何与我想知道的单线程命令式方法相提并论......
因此,也许更重要的问题是:
“鉴于单个内核不会变得更快,并且同步/锁定对并行化提出了真正的挑战,可变性是否昂贵?”
【讨论】:
那里没有参数。根据定义,快速排序是一种内存排序。我相信大多数人从大学开始就记得这一点。但是,你如何以纯函数的方式快速排序。即没有副作用。 它的重要原因是,有人声称函数范式可以与具有副作用的函数一样快。 列表版将时间缩短一半。仍然没有任何接近 java 版本的速度。 你能解释一下为什么你认为 Scala 不是必须的吗?list.filter (foo).sort (bar).take (10)
- 还有什么比这更重要的呢?谢谢。
@user unknown:也许您可以澄清您认为“势在必行”的含义,因为您陈述的示例对我来说看起来很实用。 Scala 本身既不是命令式的也不是声明式的,该语言同时支持这两种风格,这些术语最适合用来描述具体的例子。【参考方案9】:
据说 OO 编程使用抽象来隐藏复杂性,而函数式编程使用不变性来消除复杂性。在 Scala 的混合世界中,我们可以使用 OO 来隐藏命令式代码,而让应用程序代码更加明智。确实,集合库使用了大量的命令式代码,但这并不意味着我们不应该使用它们。正如其他人所说,小心使用,你真的可以在这里两全其美。
【讨论】:
你能解释一下为什么你认为 Scala 不是必须的吗?list.filter (foo).sort (bar).take (10)
- 还有什么更重要的?谢谢。
我不明白他在哪里说 Scala 不是必须的。以上是关于函数式编程 - 不变性昂贵吗? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章