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

2022-08-31 10:05:37

问题分为两部分。首先是概念性的。接下来在Scala中更具体地研究同样的问题。

  1. 在编程语言中仅使用不可变的数据结构是否会使实现某些算法/逻辑在实践中固有的计算成本更高?这就引出了一个事实,即不可变性是纯函数式语言的核心原则。还有其他因素会影响这一点吗?
  2. 让我们举一个更具体的例子。快速排序通常使用内存中数据结构上的可变操作进行教学和实现。如何以纯函数方式实现这样的事情,其计算和存储开销与可变版本相当。特别是在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 Code 用于比较实现

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

答案 1

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

  • “就地”快速排序并不是真正的就地(根据定义,快速排序不是就地)。它需要以堆栈空间的形式为递归步骤提供额外的存储,在最好的情况下,它的顺序为O(log n),但在最坏的情况下为On)。

  • 实现对数组进行操作的快速排序的功能变体会破坏目的。数组从来都不是不可变的。

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

    平均而言,它的运行时间仍然与就地变体(On log n))相当。然而,它的空间复杂度仍然是On)。


函数式快速排序实现有两个明显的缺点。在下文中,让我们从Haskell介绍中考虑Haskell中的这个参考实现(我不知道Scala...):

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

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

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


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

但是,当约束到不可变域时,此算法的性能仍然较差。这样做的原因是,该算法具有一种特殊的特性,即受益于许多(有时是低级的)微调,这些微调只能在数组上有效地执行。对快速排序的幼稚描述会忽略所有这些复杂性(无论是在功能变体中还是在程序变体中)。

在阅读了“设计排序函数”之后,我不能再认为快速排序是一种优雅的算法。高效实施,这是一个笨拙的混乱,一个工程师的作品,而不是一个艺术家的作品(不要贬低工程的价值!这有自己的美学)。


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

而且,不可变性甚至可以通过消除对昂贵的拷贝或跨线程同步的需求来降低性能成本。

因此,为了回答最初的问题,“不变性是否昂贵?”——在快速排序的特殊情况下,存在一种成本,这确实是不变性的结果。但总的来说,没有


答案 2

作为函数式编程的基准,有很多错误。亮点包括:

  • 您使用的是基元,可能必须将其装箱/取消装箱。你不是在尝试测试包装基元对象的开销,而是在尝试测试不可变性。
  • 您选择了一种算法,其中就地操作异常有效(并且可以证明如此)。如果你想证明存在算法在可变实现时更快,那么这是一个不错的选择;否则,这可能会产生误导。
  • 您使用了错误的计时函数。用。System.nanoTime
  • 基准测试太短,您无法确信 JIT 编译不会是测量时间的重要部分。
  • 数组未以有效方式拆分。
  • 数组是可变的,因此将它们与FP一起使用无论如何都是一个奇怪的比较。

因此,这种比较很好地说明了您必须详细了解您的语言(和算法)才能编写高性能代码。但这并不是FP与非FP的很好比较。如果你想要这样,请查看计算机语言基准游戏中的Haskell vs. C++。带回家的信息是,处罚通常不超过2或3左右的系数,但这确实取决于。(也没有承诺Haskell的人已经编写了最快的算法,但至少他们中的一些人可能尝试过!再说一遍,一些Haskell调用C库....)

现在,假设您确实想要一个更合理的Quicksort基准,认识到这可能是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
  }
}

请注意对功能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 方法,该方法返回三个数组:小于数组、等于数组和大于透视的数组。这可能会稍微加快速度。