繁体   English   中英

Scala 惯用的编码风格只是编写低效代码的一个很酷的陷阱吗?

[英]Is Scala idiomatic coding style just a cool trap for writing inefficient code?

我感觉到 Scala 社区对编写“简洁”、“酷”、 “scala 惯用” 、“单行”(如果可能的话)代码有点痴迷。 紧随其后的是与 Java/命令式/丑陋代码的比较。

虽然这(有时)会导致代码易于理解,但也会导致 99% 的开发人员代码效率低下。 这就是 Java/C++ 不容易被击败的地方。

考虑这个简单的问题:给定一个整数列表,删除最大的元素。 订单不需要保留。

这是我的解决方案版本(它可能不是最好的,但它是普通的非摇滚明星开发者会做的)。

def removeMaxCool(xs: List[Int]) = {
  val maxIndex = xs.indexOf(xs.max);
  xs.take(maxIndex) ::: xs.drop(maxIndex+1)
}

它是 Scala 惯用的、简洁的,并使用了一些不错的列表函数。 这也是非常低效的。 它至少遍历列表 3 或 4 次。

这是我完全不酷的、类似 Java 的解决方案。 这也是一个合理的 Java 开发人员(或 Scala 新手)会写的内容。

def removeMaxFast(xs: List[Int]) = {
    var res = ArrayBuffer[Int]()
    var max = xs.head
    var first = true;   
    for (x <- xs) {
        if (first) {
            first = false;
        } else {
            if (x > max) {
                res.append(max)
                max = x
            } else {
                res.append(x)
            }
        }
    }
    res.toList
}

完全非 Scala 惯用的、非功能性的、不简洁的,但它非常有效。 它只遍历列表一次!

因此,如果 99% 的 Java 开发人员编写的代码比 99% 的 Scala 开发人员编写的代码效率更高,那么这对于 Scala 的采用来说是一个巨大的障碍。 有没有办法摆脱这个陷阱?

我正在寻找实用的建议来避免这种“低效率陷阱”,同时保持实施的清晰和简洁。

澄清:这个问题来自现实生活场景:我必须编写一个复杂的算法。 首先我在 Scala 中编写它,然后我“不得不”在 Java 中重写它。 Java 的实现时间是原来的两倍,而且不是很清楚,但同时速度是原来的两倍。 重写 Scala 代码以提高效率可能需要一些时间,并且对 scala 内部效率有更深入的了解(对于与 map 与折叠等)

让我们讨论一下问题中的一个谬误:

因此,如果 99% 的 Java 开发人员编写的代码比 99% 的 Scala 开发人员编写的代码效率更高,那么这对于 Scala 的采用来说是一个巨大的障碍。 有没有办法摆脱这个陷阱?

这是推测的,完全没有证据支持它。 如果为假,则问题没有实际意义。

有相反的证据吗? 好吧,让我们考虑一下这个问题本身——它不能证明任何事情,但表明事情并不那么清楚。

完全非 Scala 惯用的、非功能性的、不简洁的,但它非常有效。 它只遍历列表一次!

在第一句的四个声明中,前三个是真实的,第四个,如用户 unknown所示,是假的? 以及为什么它是错误的,因为,与第二句话所说的相反。 它不止一次地遍历列表。

该代码在其上调用以下方法:

res.append(max)
res.append(x)

res.toList

让我们首先考虑append

  1. append采用可变参数。 这意味着maxx首先被封装成某种类型的序列(实际上是WrappedArray ),然后作为参数传递。 更好的方法是+=

  2. 好的, append调用++= ,它委托给+= 但是,首先,它调用ensureSize ,这是第二个错误( +=也调用了 - ++=只是针对多个元素优化了它)。 因为Array是一个固定大小的集合,这意味着在每次调整大小时,都必须复制整个Array

所以让我们考虑一下。 调整大小时,Java 首先通过在每个元素中存储 0 来清除 memory,然后 Scala 将前一个数组的每个元素复制到新数组中。 由于大小每次翻倍,这会发生 log(n) 次,每次复制的元素数量都会增加。

以 n = 16 为例。它这样做了四次,分别复制 1、2、4 和 8 个元素。 由于Java要清除这些arrays中的每一个,并且每个元素都必须读写所以复制的每个元素代表一个元素的4次遍历。 添加我们拥有的所有 (n - 1) * 4,或者,大致是完整列表的 4 次遍历。 如果你把读写算作一次遍历,就像人们经常错误地做的那样,那么它仍然是三个遍历。

可以通过初始化ArrayBuffer来改进这一点,其初始大小等于将要读取的列表减去一,因为我们将丢弃一个元素。 不过,要获得这个大小,我们需要遍历列表一次。

现在让我们考虑toList 简单来说,就是遍历整个列表,创建一个新列表。

因此,我们对算法进行了 1 次遍历,对调整大小进行了 3 或 4 次遍历,对toList进行了 1 次额外的遍历。 那是 4 或 5 次遍历。

原始算法有点难以分析,因为takedrop:::遍历可变数量的元素。 但是,将所有内容加在一起相当于 3 次遍历。 如果使用splitAt ,它将减少到 2 次遍历。 再进行 2 次遍历以获得最大值,我们得到 5 次遍历——与非功能性、非简洁算法相同的次数!

所以,让我们考虑改进。

在命令式算法中,如果使用ListBuffer+= ,则所有方法都是恒定时间的,这将其减少为一次遍历。

在函数算法上,它可以重写为:

val max = xs.max
val (before, _ :: after) = xs span (max !=)
before ::: after

这将其减少到三个遍历的最坏情况。 当然,还有其他基于递归或折叠的替代方案,可以在一次遍历中解决它。

而且,最有趣的是,所有这些算法都是O(n) ,并且唯一几乎(意外)导致最复杂的算法是命令式算法(因为数组复制)。 另一方面,命令式的缓存特性可能会使其更快,因为 memory 中的数据是连续的。 然而,这与 big-Oh 或函数式与命令式无关,这只是所选择的数据结构的问题。

因此,如果我们真的不麻烦进行基准测试、分析结果、考虑方法的性能并研究优化方法,那么我们可以找到以命令式方式比以函数式方式更快地执行此操作的方法。

但是所有这些努力与说平均 Java 程序员代码将比平均 Scala 程序员代码快的说法大不相同——如果问题是一个例子,那简直是错误的。 即使不考虑这个问题,我们也没有看到任何证据表明问题的基本前提是正确的。

编辑

首先,让我重申一下我的观点,因为我似乎并不清楚。 我的观点是,平均 Java 程序员编写的代码似乎更有效,但实际上并非如此。 或者,换一种说法,传统的 Java 风格并不能提高你的表现——只有努力工作才能做到,无论是 Java 还是 Scala。

接下来,我也有一个基准和结果,包括几乎所有建议的解决方案。 关于它的两个有趣的点:

  1. 根据列表大小,对象的创建可能比列表的多次遍历产生更大的影响。 Adrian 的原始功能代码利用了列表是持久数据结构这一事实,根本不复制最大元素的右侧元素。 如果改为使用Vector ,则左右两侧将基本保持不变,这可能会带来更好的性能。

  2. 尽管用户未知和范式具有相似的递归解决方案,但范式更快。 原因是他避免了模式匹配。 模式匹配可能真的很慢。

基准代码在这里,结果在这里

def removeOneMax (xs: List [Int]) : List [Int] = xs match {                                  
    case x :: Nil => Nil 
    case a :: b :: xs => if (a < b) a :: removeOneMax (b :: xs) else b :: removeOneMax (a :: xs) 
    case Nil => Nil 
}

这是一个递归方法,它只迭代一次。 如果你需要性能,你必须考虑它,如果不是,不是。

您可以以标准方式使其尾递归:提供额外的参数carry ,默认情况下为空列表,并在迭代时收集结果。 那当然要长一点,但是如果你需要性能,你就得为此付出代价:

import annotation.tailrec 
@tailrec
def removeOneMax (xs: List [Int], carry: List [Int] = List.empty) : List [Int] = xs match {                                  
  case a :: b :: xs => if (a < b) removeOneMax (b :: xs, a :: carry) else removeOneMax (a :: xs, b :: carry) 
  case x :: Nil => carry 
  case Nil => Nil 
}

我不知道有多少机会,以后的编译器会将较慢的映射调用改进为与 while 循环一样快。 然而:你很少需要高速解决方案,但如果你经常需要它们,你会很快学会它们。

您知道您的收藏必须有多大,才能在您的机器上使用一整秒的时间来解决您的问题吗?

作为oneliner,类似于Daniel C。 索布拉斯解决方案:

((Nil : List[Int], xs(0)) /: xs.tail) ((p, x)=> if (p._2 > x) (x :: p._1, p._2) else ((p._2 :: p._1), x))._1

但这很难阅读,而且我没有衡量有效性能。 正常模式是 (x /: xs) ((a, b) => /* something */)。 这里,x 和 a 是 List-so-far 和 max-so-far 的对,它解决了将所有内容都放入一行代码的问题,但可读性不强。 但是,您可以通过这种方式在 CodeGolf 上赢得声誉,并且可能有人喜欢进行性能测量。

现在让我们大吃一惊的是,一些测量结果:

更新的计时方法,以消除垃圾收集,并让热点编译器预热,主线程和该线程中的许多方法,一起在名为 Object 中

object PerfRemMax {

  def timed (name: String, xs: List [Int]) (f: List [Int] => List [Int]) = {
    val a = System.currentTimeMillis 
    val res = f (xs)
    val z = System.currentTimeMillis 
    val delta = z-a
    println (name + ": "  + (delta / 1000.0))
    res
  }

def main (args: Array [String]) : Unit = {
  val n = args(0).toInt
  val funs : List [(String, List[Int] => List[Int])] = List (
    "indexOf/take-drop" -> adrian1 _, 
    "arraybuf"      -> adrian2 _, /* out of memory */
    "paradigmatic1"     -> pm1 _, /**/
    "paradigmatic2"     -> pm2 _, 
    // "match" -> uu1 _, /*oom*/
    "tailrec match"     -> uu2 _, 
    "foldLeft"      -> uu3 _,
    "buf-=buf.max"  -> soc1 _, 
    "for/yield"     -> soc2 _,
    "splitAt"       -> daniel1,
    "ListBuffer"    -> daniel2
    )

  val r = util.Random 
  val xs = (for (x <- 1 to n) yield r.nextInt (n)).toList 

// With 1 Mio. as param, it starts with 100 000, 200k, 300k, ... 1Mio. cases. 
// a) warmup
// b) look, where the process gets linear to size  
  funs.foreach (f => {
    (1 to 10) foreach (i => {
        timed (f._1, xs.take (n/10 * i)) (f._2)
        compat.Platform.collectGarbage
    });
    println ()
  })
}

我重命名了所有方法,并且不得不稍微修改 uu2 以适应通用方法声明(List [Int] => List [Int])。

从长结果来看,我只为 1M 调用提供了 output:

scala -Dserver PerfRemMax 2000000
indexOf/take-drop:  0.882
arraybuf:   1.681
paradigmatic1:  0.55
paradigmatic2:  1.13
tailrec match: 0.812
foldLeft:   1.054
buf-=buf.max:   1.185
for/yield:  0.725
splitAt:    1.127
ListBuffer: 0.61

这些数字并不完全稳定,具体取决于样本大小,并且每次运行都略有不同。 例如,对于 100k 到 1M 的运行,以 100k 为步长,splitAt 的时序如下:

splitAt: 0.109
splitAt: 0.118
splitAt: 0.129
splitAt: 0.139
splitAt: 0.157
splitAt: 0.166
splitAt: 0.749
splitAt: 0.752
splitAt: 1.444
splitAt: 1.127

最初的解决方案已经非常快了。 splitAt是对 Daniel 的修改,通常更快,但并非总是如此。

测量是在单核 2Ghz Centrino 上完成的,运行 xUbuntu Linux、Scala-2.8 和 Sun-Java-1.6(桌面)。

给我的两个教训是:

  • 始终衡量您的绩效改进; 很难估计,如果你不每天做的话
  • 编写函数式代码不仅很有趣 - 有时结果甚至更快

如果有人感兴趣,这是我的基准代码的链接。

首先,您提出的方法的行为是不一样的。 第一个保持元素顺序,而第二个不保持。

其次,在所有可能被称为“惯用”的解决方案中,有些解决方案比其他解决方案更有效。 与您的示例非常接近,例如,您可以使用尾递归来消除变量和手动 state 管理:

def removeMax1( xs: List[Int] ) = {
  def rec( max: Int, rest: List[Int], result: List[Int]): List[Int] = {
    if( rest.isEmpty ) result
    else if( rest.head > max ) rec( rest.head, rest.tail, max :: result)
    else rec( max, rest.tail, rest.head :: result )
  }
  rec( xs.head, xs.tail, List() )
}

或折叠列表:

def removeMax2( xs: List[Int] ) = {
  val result = xs.tail.foldLeft( xs.head -> List[Int]() ) { 
    (acc,x) =>
      val (max,res) = acc
      if( x > max ) x -> ( max :: res )
      else max -> ( x :: res )
  }
  result._2
}

如果您想保留原始插入顺序,您可以(以两次而不是一次为代价)毫不费力地编写如下内容:

def removeMax3( xs: List[Int] ) = {
  val max = xs.max
  xs.filterNot( _ == max )
}

这比你的第一个例子更清楚。

编写程序时最大的低效率是担心错误的事情。 这通常是错误的担心。 为什么?

  1. 开发人员时间通常比 CPU 时间昂贵得多——事实上,通常前者缺乏而后者则过剩。

  2. 大多数代码不需要非常高效,因为它永远不会每秒多次在百万项目数据集上运行。

  3. 大多数代码确实需要消除错误,并且代码越少隐藏错误的空间就越小。

实际上,您给出的示例并不是很实用。 这就是你正在做的事情:

// Given a list of Int
def removeMaxCool(xs: List[Int]): List[Int] = {

  // Find the index of the biggest Int
  val maxIndex = xs.indexOf(xs.max);

  // Then take the ints before and after it, and then concatenate then
  xs.take(maxIndex) ::: xs.drop(maxIndex+1)
}

请注意,这还不错,但是当函数式代码描述您想要的而不是您想要的方式时,您知道什么时候功能代码处于最佳状态。 作为一个小批评,如果您使用splitAt而不是takedrop ,您可以稍微改进它。

另一种方法是:

def removeMaxCool(xs: List[Int]): List[Int] = {
  // the result is the folding of the tail over the head 
  // and an empty list
  xs.tail.foldLeft(xs.head -> List[Int]()) {

    // Where the accumulated list is increased by the
    // lesser of the current element and the accumulated
    // element, and the accumulated element is the maximum between them
    case ((max, ys), x) => 
      if (x > max) (x, max :: ys)
      else (max, x :: ys)

  // and of which we return only the accumulated list
  }._2
}

现在,让我们讨论主要问题。 这段代码比 Java 慢吗? 最确定? Java 代码是否比 C 等效代码慢,您可以打赌。 JIT 或无 JIT,如果你直接用汇编程序编写它,你可以让它更快!

但是这种速度的代价是你会得到更多的错误,你会花费更多的时间来尝试理解代码来调试它,并且你对整个程序正在做什么而不是一小段代码在做什么的可见性更少 - - 这可能会导致其自身的性能问题。

所以我的回答很简单:如果你认为 Scala 编程的速度损失不值得它带来的收益,你应该用汇编程序编程。 如果你认为我是激进的,那么我反驳说你只是选择了熟悉的作为“理想”的权衡。

我认为性能不重要吗? 一点也不,我认为 Scala 的主要优势之一是利用动态类型语言中常见的收益和静态类型语言的性能,性能很重要。 算法复杂性很重要,固定成本也很重要。

但是,每当在性能和可读性和可维护性之间进行选择时,后者是更可取的。 当然,如果必须提高性能,那就别无选择:你必须为此牺牲一些东西。 如果可读性/可维护性没有损失——例如 Scala 与动态类型语言——当然,go 的性能。

最后,要从函数式编程中获得性能,您必须了解函数式算法和数据结构。 当然,拥有 5-10 年经验的 Java 程序员的 99% 将击败拥有 6 个月经验的 Scala 程序员的 99% 的性能。 几十年前,命令式编程与面向 object 的编程也是如此,历史表明这并不重要。

编辑

作为旁注,您的“快速”算法存在严重问题:您使用ArrayBuffer 该集合没有恒定时间 append,并且具有线性时间toList 如果您改用ListBuffer ,您将获得恒定时间 appendtoList

作为参考,这里是如何在splitAt标准库中的TraversableLike中定义 splitAt,

def splitAt(n: Int): (Repr, Repr) = {
  val l, r = newBuilder
  l.sizeHintBounded(n, this)
  if (n >= 0) r.sizeHint(this, -n)
  var i = 0
  for (x <- this) {
    (if (i < n) l else r) += x
    i += 1
  }
  (l.result, r.result)
}

这与 Java 程序员可能想出的示例代码没有什么不同。

我喜欢 Scala,因为在性能很重要的地方,可变性是 go 的合理方法。 collections 库就是一个很好的例子; 尤其是它如何将这种可变性隐藏在功能接口后面。

在性能不那么重要的地方,例如一些应用程序代码,Scala 库中的高阶函数允许出色的表现力和程序员的效率。


Out of curiosity, I picked an arbitrary large file in the Scala compiler ( scala.tools.nsc.typechecker.Typers.scala ) and counted something like 37 for loops, 11 while loops, 6 concatenations ( ++ ), and 1 fold (它恰好是foldRight )。

那这个呢?

def removeMax(xs: List[Int]) = {
  val buf = xs.toBuffer
  buf -= (buf.max)
}

有点丑,但更快:

def removeMax(xs: List[Int]) = {
  var max = xs.head
  for ( x <- xs.tail ) 
  yield {
    if (x > max) { val result = max; max = x; result}
    else x
  }
}

尝试这个:

(myList.foldLeft((List[Int](), None: Option[Int]))) {
  case ((_, None),     x) => (List(),               Some(x))
  case ((Nil, Some(m), x) => (List(Math.min(x, m)), Some(Math.max(x, m))
  case ((l, Some(m),   x) => (Math.min(x, m) :: l,  Some(Math.max(x, m))
})._1

惯用的,功能性的,只遍历一次。 如果你不习惯函数式编程习惯,可能有点神秘。

让我们试着解释一下这里发生了什么。 我会尽量让它简单,缺乏一些严谨性。

折叠是对List[A] (即包含类型A元素的列表)的操作,它将采用初始 state s0: S (即类型S的实例)和 function f: (S, A) => S (that is, a function that takes the current state and an element from the list, and gives the next state, ie, it updates the state according to the next element).

然后该操作将遍历列表的元素,使用每个元素根据给定的 function 更新 state。 在 Java 中,它将类似于:

interface Function<T, R> { R apply(T t); }
class Pair<A, B> { ... }
<State> State fold(List<A> list, State s0, Function<Pair<A, State>, State> f) {
  State s = s0;
  for (A a: list) {
    s = f.apply(new Pair<A, State>(a, s));
  }
  return s;
}

For example, if you want to add all the elements of a List[Int] , the state would be the partial sum, that would have to be initialized to 0, and the new state produced by a function would simply add the current state to当前正在处理的元素:

myList.fold(0)((partialSum, element) => partialSum + element)

尝试编写一个折叠以将列表的元素相乘,然后再编写一个折叠以找到极值(最大值,最小值)。

现在,上面显示的折叠有点复杂,因为 state 由正在创建的新列表以及迄今为止找到的最大元素组成。 一旦您掌握了这些概念,更新 state 的 function 或多或少就很简单了。 它只是将当前最大值和当前元素之间的最小值放入新列表中,而另一个值进入更新后的 state 的当前最大值。

比理解这一点(如果你没有 FP 背景)更复杂的是想出这个解决方案。 然而,这只是向你表明它存在,是可以做到的。 这只是一种完全不同的心态。

编辑:如您所见,我提出的解决方案中的第一种和第二种case用于设置折叠。 当他们执行xs.tail.fold((xs.head, ...)) {...}时,它等同于您在其他答案中看到的内容。 请注意,到目前为止,使用xs.tail/xs.head提出的解决方案并未涵盖xsList()的情况,并且会引发异常。 上面的解决方案将返回List() 由于您没有在空列表上指定 function 的行为,因此两者都是有效的。

另一个竞争者。 这使用了一个 ListBuffer,就像 Daniel 的第二个产品一样,但共享原始列表的 post-max 尾部,避免复制它。

  def shareTail(xs: List[Int]): List[Int] = {
    var res = ListBuffer[Int]()
    var maxTail = xs
    var first = true;
    var x = xs
    while ( x != Nil ) {
      if (x.head > maxTail.head) {
          while (!(maxTail.head == x.head)) {
              res += maxTail.head
              maxTail = maxTail.tail
          }
      }
      x = x.tail
    }
    res.prependToList(maxTail.tail)
  }

另一种选择是:

package code.array

object SliceArrays {
  def main(args: Array[String]): Unit = {
    println(removeMaxCool(Vector(1,2,3,100,12,23,44)))
  }
  def removeMaxCool(xs: Vector[Int]) = xs.filter(_ < xs.max)
}

使用 Vector 而不是 List,原因是 Vector 比 List 更通用,具有更好的通用性能和时间复杂度。

考虑以下 collections 操作: head、tail、apply、update、prepend、append

根据 Scala 文档,向量对所有操作都需要一个摊销的常数时间:“该操作需要有效的常数时间,但这可能取决于一些假设,例如向量的最大长度或 hash 键的分布”

而 List 仅对 head、tail 和 prepend 操作花费恒定的时间。

使用

scalac 打印

生成:

package code.array {
  object SliceArrays extends Object {
    def main(args: Array[String]): Unit = scala.Predef.println(SliceArrays.this.removeMaxCool(scala.`package`.Vector().apply(scala.Predef.wrapIntArray(Array[Int]{1, 2, 3, 100, 12, 23, 44})).$asInstanceOf[scala.collection.immutable.Vector]()));
    def removeMaxCool(xs: scala.collection.immutable.Vector): scala.collection.immutable.Vector = xs.filter({
  ((x$1: Int) => SliceArrays.this.$anonfun$removeMaxCool$1(xs, x$1))
}).$asInstanceOf[scala.collection.immutable.Vector]();
    final <artifact> private[this] def $anonfun$removeMaxCool$1(xs$1: scala.collection.immutable.Vector, x$1: Int): Boolean = x$1.<(scala.Int.unbox(xs$1.max(scala.math.Ordering$Int)));
    def <init>(): code.array.SliceArrays.type = {
      SliceArrays.super.<init>();
      ()
    }
  }
}

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM