簡體   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