函数式编程-不变性昂贵吗?[关闭]


98

问题分为两部分。首先是概念上的。接下来的内容将在Scala中更具体地研究相同的问题。

  1. 在编程语言中仅使用不可变数据结构是否会使在实践中实现某些算法/逻辑本质上在计算上更加昂贵?这引出了一个事实,即不变性是纯功能语言的核心宗旨。还有其他因素对此有影响吗?
  2. 让我们举一个更具体的例子。通常对内存数据结构使用可变操作来教授和实现Quicksort。如何以一种PURE功能方式实现这种事情,并且具有与可变版本相当的计算和存储开销。特别是在Scala中。我在下面列出了一些粗略的基准。

更多细节:

我来自命令式编程背景(C ++,Java)。我一直在探索函数式编程,特别是Scala。

纯函数式编程的一些主要原理:

  1. 职能是头等公民。
  2. 函数没有副作用,因此对象/数据结构是不可变的

即使现代JVM在创建对象方面非常高效,并且垃圾回收对于短寿命的对象而言非常便宜,但将对象创建最小化可能还是更好的选择吧?至少在并发和锁定不是问题的单线程应用程序中。由于Scala是一种混合范例,因此可以根据需要选择使用可变对象编写命令性代码。但是,作为一个花了很多年尝试重用对象并最小化分配的人。我希望对不允许的思想流派有所了解。

作为一个具体案例,我对本教程 6中的代码片段感到有些惊讶。它有一个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

10
如果天真地实现或使用针对命令性语言而开发的方法,则这是昂贵的。智能编译器(例如GHC,Haskell编译器-Haskell 具有不可变的值)可以利用不可变性和出色的性能,这些性能可以与使用可变性的代码相媲美。不用说,quicksort的幼稚实现仍然很慢,因为它使用了繁琐的递归和O(n)列表连接等昂贵的事情。它比伪代码版本要短;)

3
一篇很棒的相关博客文章位于:blogs.sun.com/jrose/entry/larval_objects_in_the_vm 鼓励他加入,因为这将使Java和功能性VM语言
受益匪浅

2
这个SO线程对函数式编程的效率进行了很多详细的讨论。stackoverflow.com/questions/1990464/…。回答了很多我想知道的事情。
smartnut007

5
这里最幼稚的是您的基准。您不能使用这样的代码来进行基准测试!在得出任何结论之前,您应该认真阅读一些关于在JVM上进行基准测试的文章……您是否知道JVM在运行之前可能尚未将代码JIT化?您是否适当地设置了堆的初始大小和最大大小(以便您不考虑JVM进程要求更多内存的时间?)?您知道正在编译或重新编译哪些方法吗?您知道GC吗?您从此代码获得的结果绝对没有任何意义!
布鲁诺·里斯

2
@userunknown不,这是声明性的。命令式编程“用命令改变状态”,而功能性编程“是声明式编程范例”,“避免改变状态”(维基百科)。因此,是的,功能性和命令性是完全不同的两件事,并且您编写的代码不是强制性的。
Brian McCutchon

Answers:


105

由于这里存在一些误解,我想澄清一些要点。

  • “就地”快速排序不是真正的就地(按定义,快速排序也不是就地)。对于递归步骤,它需要堆栈空间形式的附加存储,其顺序为: ø(日志Ñ)在最佳情况下,但øÑ在最坏的情况下)。

  • 实现对数组进行操作的quicksort的功能变体无法达到目的。数组永远是不可变的。

  • 快速排序的“适当”功能实现使用不可变列表。它当然不是就地,但它具有相同的最坏情况渐近运行时(On ^ 2))和空间复杂度(On))。

    平均而言,其运行时间与就地变型(On log n))相同。但是,其空间复杂度仍为On)。


功能快速排序实现两个明显的缺点。下面,让我们考虑在Haskell(我不知道斯卡拉...)从这个参考实现哈斯克尔介绍

qsort []     = []
qsort (x:xs) = qsort lesser ++ [x] ++ qsort greater
    where lesser  = (filter (< x) xs)
          greater = (filter (>= x) xs)
  1. 第一个缺点是 枢轴元件的选择非常不灵活。现代快速排序实现的强大功能在很大程度上取决于对数据透视表的明智选择(比较Bentley 等人的 “设计排序功能”)。在这方面,上述算法很差,这会大大降低平均性能。

  2. 其次,该算法使用列表级联On)操作的(而不是列表构造)。这不会影响渐近复杂性,但这是一个可测量的因素。

第三个缺点是隐藏的:与“就地”变体不同,此实现 不断从堆中请求列表的cons单元的内存,并有可能将内存分散到整个位置。结果,该算法的缓存局部性很差。我不知道现代函数式编程语言中的智能分配器是否可以缓解这种情况-但在现代计算机上,缓存未命中已成为主要的性能杀手。


结论如何?与其他人不同,我不会说quicksort本质上是必不可少的,这就是为什么它在FP环境中表现不佳的原因。恰恰相反,我认为快速排序是功能算法的完美示例:它无缝地转换为不可变的环境,其渐近运行时间和空间复杂度与过程实现相当,甚至其过程实现也使用递归。

但是这个算法 当将约束到不可变域时,其仍然较差。这样做的原因是该算法具有独特的特性,即可以从很多(有时是低级别的)微调中受益,这些微调只能在数组上有效地执行。对快速排序的幼稚描述忽略了所有这些复杂性(在功能和过程上都是如此)。

阅读“设计排序功能”后,我不再认为quicksort是一种优雅的算法。有效地实施,这是一个笨拙的混乱,是工程师的工作,而不是艺术家的工作(不要贬低工程学!这有其自身的美学意义)。


但我也想指出,这一点是Quicksort特有的。并非每种算法都适用于相同类型的低级调整。确实有很多算法和数据结构在不可变的环境中,可以表达而不会降低性能。

不变性甚至可以 消除对昂贵副本或跨线程同步的需求,从而降低性能成本。

因此,回答最初的问题,“ 不变性昂贵吗?” –在快速分类的特定情况下,确实存在成本,这是不可变性的结果。但总的来说,没有


10
+1-好答案!虽然我个人有时会以结束而不是没有结束。不过,这只是个性-您已经很好地解释了这些问题。
Rex Kerr 2010年

6
您应该补充一点,与命令式版本相反,使用不可变值的正确实现可以立即并行化。在现代技术背景下,这变得越来越重要。
拉斐尔

使用qsort lesser ++ (x : qsort greater)帮助有多少?
所罗门·乌科

42

作为函数式编程的基准,这有很多问题。重点包括:

  • 您正在使用图元,可能必须将它们装箱/拆箱。您不是要测试包装原始对象的开销,而是要测试不变性。
  • 您选择的算法在就地运算中异常有效(并且证明是有效的)。如果您想证明存在可变实现时更快的算法,那么这是一个不错的选择。否则,这可能会产生误导。
  • 您使用了错误的计时功能。使用System.nanoTime
  • 基准测试太短了,您无法确信JIT编译不会在所测时间中占很大的比例。
  • 数组未有效拆分。
  • 数组是可变的,因此将它们与FP一起使用还是一个奇怪的比较。

因此,此比较很好地说明了您必须详细了解语言(和算法)才能编写高性能代码。但这并不是FP与非FP的很好比较。如果需要的话,请在Computer Languages Benchmark Game中查看Haskell vs. C ++。带回家的消息是,罚款通常不超过2或3倍左右,但这确实取决于。(也不保证Haskell的人已经编写了可能最快的算法,但至少其中一些人可能尝试过!然后,某些Haskell调用C库...。)

现在,假设您确实想要一个更合理的Quicksort基准,并意识到这可能是FP与可变算法最坏的情况之一,并且忽略了数据结构问题(即,假设我们可以拥有一个不变的Array):

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
  }
}

请注意对功能性Quicksort的修改,因此,只要有可能,它仅对数据进行一次遍历,并与内置排序进行比较。当我们运行它时,我们得到如下内容:

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的惩罚吗?Isnt Int Scala(vs Integer)的首选数字类型。因此,在scala侧没有拳击障碍。装箱只是Java方面的一个问题,因为自动装箱会将scala Int形式装箱到java.lang.Integer / int。这是一个详细讨论此主题的链接ansorg-it.com/en/scalanews-001.html
smartnut007 2010年

是的,我在这里扮演魔鬼拥护者。可变性是快速分类设计的重要组成部分。这就是为什么我对解决问题的纯功能方法非常好奇。igh,我已经在线程上第十次说了这句话:-)。当我醒来并返回时,将查看您其余的帖子。谢谢。
smartnut007 2010年

2
@ smartnut007-Scala集合是通用的。泛型在大多数情况下都需要盒装类型(尽管正在努力使它们专用于某些原始类型)。因此,您不能使用所有漂亮的collections方法,并假设通过它们传递原始类型的collection不会受到任何惩罚。很可能原始类型必须在进站时装箱,而在出站时拆箱。
Rex Kerr

我不喜欢您所说的最大缺陷仅仅是一种推测:-)
smartnut007 2010年

1
@ smartnut007-这是一个主要缺陷,因为它很难检查,如果为true则真的会破坏结果。如果您确定没有拳击,那么我同意该缺陷无效。该漏洞不说,还有就是拳击,那就是你不知道你是否有拳击(我也不清楚-专业化使这一棘手弄清楚)。在Java方面(或Scala可变实现),没有装箱,因为您仅使用原语。无论如何,一个不变的版本可以在n log n个空间中工作,因此您最终将比较/交换的成本与内存分配进行了比较。
Rex Kerr 2010年

10

我不认为Scala版本实际上是尾递归的,因为您正在使用Array.concat

另外,仅因为这是惯用的Scala代码,但这并不意味着它是最佳方法。

最好的方法是使用Scala的内置排序功能之一。这样,您将获得不变性保证,并且知道自己拥有快速算法。

请参阅堆栈溢出问题。如何在Scala中对数组排序?举个例子


4
另外,我不认为有可能进行尾递归快速排序,因为您必须进行两次递归调用
Alex Lo 2010年

1
有可能,您只需要使用延续闭包即可将要堆叠的框架提升到堆上。
布赖恩

内置的scala.util.Sorting.quickSort(array)使数组变异。它的运行速度与Java一样快,这并不奇怪。我对高效的纯功能解决方案感兴趣。如果没有,为什么。通常它是Scala或功能范式的限制。那种事。
smartnut007

@ smartnut007:您使用的是什么版本的Scala?在Scala 2.8中,您可以执行以下操作:array.sorted返回一个新的排序数组,而不更改原始数组。
missingfaktor

@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;
Jakeway '16

8

不变性并不昂贵。如果只测量程序要做的一小部分任务,然后根据启动的可变性选择解决方案,例如测量快速排序,那肯定会很昂贵。

简而言之,在使用纯函数式语言时,您不会快速排序。

让我们从另一个角度考虑。让我们考虑以下两个功能:

// 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)
}

使用基准THAT,您会发现使用可变数据结构的代码的性能要差得多,因为它需要复制数组,而不可变的代码则不必担心。

当使用不可变的数据结构进行编程时,可以对代码进行结构化以利用其优势。它不仅是数据类型,甚至不是单独的算法。该程序将被设计不同的方式。

这就是基准测试通常毫无意义的原因。您要么选择一种样式或另一种样式固有的算法,然后以该样式为准,要么对整个应用程序进行基准测试,这通常是不切实际的。


7

排序数组是宇宙中最必要的任务。毫不奇怪,许多优雅的“不变”策略/实现在“对数组进行排序”的微基准测试中失败极少。但是,这并不意味着不变性通常是昂贵的。在许多任务中,不变的实现将与可变的实现相比,但是数组排序通常不是其中之一。


7

如果您只是将命令性算法和数据结构重写为函数式语言,那确实是昂贵且无用的。为了让事情变得光彩照人,您应该使用仅在函数式编程中可用的功能:数据结构持久性,惰性评估等。


您是否足够友善地在Scala中提供实现?
smartnut007

3
powells.com/biblio/17-0521631246-0(克里斯·冈(Chris Okasaki)的纯函数数据结构)-只需阅读本书即可。在实施有效的算法和数据结构时,它讲述了如何利用函数式编程的好处。
Vasil Remeniuk

1
code.google.com/p/pfds Debashish Ghosh在Scala中实现的一些数据结构
Vasil Remeniuk,2010年

您能解释一下为什么您认为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的好处是您可以根据需要利用可变性和不变性。缺点是,如果您做错了,您将不会真正获得不变性的好处。


尽管这并不是问题的精确答案,但我认为这是任何好的答案的一部分:使用可变结构时,Quicksort更快。但是不变性的主要优点是界面,至少在Scala中可以同时拥有两者。快速排序的可变性更快,但是这并不妨碍您编写高效的,通常是不变的代码。
保罗·德雷珀

7

众所周知,QuickSort在就地完成时会更快,因此这不是一个公平的比较!

这么说... Array.concat?如果没有其他说明,那么您将展示针对命令式编程进行优化的集合类型如何在功能算法中尝试使用时特别慢。几乎任何其他选择都会更快!


另外重要的一点考虑,也许比较这两种方法时最重要的问题是:“这种方法在多大程度上扩展到多个节点/核心?”

很有可能,如果您正在寻找一个不变的快速排序,那么您这样做是因为您实际上想要一个并行的快速排序。维基百科对此主题有一些引用: http //en.wikipedia.org/wiki/Quicksort#Parallelizations

scala版本可以在函数递归之前简单地进行派生,如果拥有足够的可用内核,它可以非常快速地对包含数十亿个条目的列表进行排序。

现在,如果我可以在其上运行Scala代码,则我系统中的GPU可以使用128个内核,并且这是在比当前一代落后两年的简单台式机系统上。

我想知道如何与单线程命令式方法相提并论...

因此,也许更重要的问题是:

“考虑到单个内核不会更快,并且同步/锁定对并行化提出了真正的挑战,可变性是否昂贵?”


那里没有争论。根据定义,快速排序是内存中的排序。我相信大多数人都记得大学时的情况。但是,您如何以纯功能方式快速排序。即没有副作用。
smartnut007

其重要原因是,有人声称功能范式可以与具有副作用的功能一样快。
smartnut007

列表版本将时间减少了一半。尽管如此,仍然没有任何接近Java版本的速度。
smartnut007

您能解释一下为什么您认为Scala并不是必须的吗?list.filter (foo).sort (bar).take (10)-什么是更必要的?谢谢。
用户未知,

@user未知:也许您可以澄清一下您认为“当务之急”的含义,因为您所说的示例对我来说很好地起作用。Scala本身既不是命令性的也不是声明性的,该语言支持两种样式,并且这些术语最适合用来描述特定的示例。
凯文·赖特

2

据说OO编程使用抽象来隐藏复杂性,而功能编程则使用不变性来消除复杂性。在Scala的混合世界中,我们可以使用OO来隐藏命令性代码,而使应用程序代码更明智。实际上,馆藏库使用了大量命令性代码,但这并不意味着我们不应该使用它们。正如其他人所说,谨慎使用,您确实可以在这里两全其美。


您能解释一下为什么您认为Scala并不是必须的吗?list.filter (foo).sort (bar).take (10)-什么是更必要的?谢谢。
用户未知,

我看不出他说Scala并不是必须的。
Janx
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.