集合 (Scala 2.8 - 2.12)

檢視

語言

集合有很多用於建構新集合的方法。範例包括 mapfilter++。我們稱這些方法為轉換器,因為它們至少將一個集合作為接收物件,並產生另一個集合作為結果。

有兩種主要方式來實作轉換器。一種是嚴格,也就是說,一個包含所有元素的新集合會建構為轉換器的結果。另一種是非嚴格或延遲,也就是說,只建構結果集合的代理,而其元素只會在需要時才建構。

以下延遲 map 操作的實作為非嚴格轉換器的範例

def lazyMap[T, U](coll: Iterable[T], f: T => U) = new Iterable[U] {
  def iterator = coll.iterator map f
}

請注意 lazyMap 會建構一個新的 Iterable,而不會逐步執行給定集合 coll 的所有元素。相反地,給定的函數 f 會套用至新集合的 iterator 的元素,因為它們是依需求而定的。

Scala 集合在預設上會在所有轉換器中嚴格執行,但 Stream 除外,它會延遲執行所有轉換器方法。不過,有一個系統性的方法可以將每個集合轉換為延遲集合,反之亦然,這基於集合檢視。檢視 是一種特殊類型的集合,它代表某個基礎集合,但會延遲執行所有轉換器。

若要從集合轉換為其檢視,您可以在集合上使用檢視方法。如果 xs 是某個集合,則 xs.view 是相同的集合,但會延遲執行所有轉換器。若要從檢視轉換回嚴格集合,您可以使用 force 方法。

讓我們看一個範例。假設您有一個 Ints 向量,您希望連續對其套用兩個函數

scala> val v = Vector(1 to 10: _*)
v: scala.collection.immutable.Vector[Int] =
   Vector(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
scala> v map (_ + 1) map (_ * 2)
res5: scala.collection.immutable.Vector[Int] =
   Vector(4, 6, 8, 10, 12, 14, 16, 18, 20, 22)

在最後一個語句中,表達式 v map (_ + 1) 建立一個新的向量,然後透過第二次呼叫 map (_ * 2) 轉換成第三個向量。在許多情況下,從第一次呼叫 map 建立中間結果有點浪費。在上面的範例中,使用 (_ + 1)(_ * 2) 這兩個函式的組合來執行單一 map 會比較快。如果您在同一個地方有這兩個函式,您可以手動執行此操作。但很多時候,資料結構的連續轉換會在不同的程式模組中執行。融合這些轉換會破壞模組化。避免中間結果的更一般方法是先將向量轉換成檢視,然後將所有轉換套用至檢視,最後強制檢視轉換成向量

scala> (v.view map (_ + 1) map (_ * 2)).force
res12: Seq[Int] = Vector(4, 6, 8, 10, 12, 14, 16, 18, 20, 22)  

讓我們再次逐一執行這系列操作

scala> val vv = v.view
vv: scala.collection.SeqView[Int,Vector[Int]] =
   SeqView(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

應用 v.view 會提供 SeqView,也就是延遲評估的 Seq。類型 SeqView 有兩個類型參數。第一個 Int 顯示檢視元素的類型。第二個 Vector[Int] 顯示強制 view 時取得的類型建構函數。

將第一個 map 套用至檢視會產生

scala> vv map (_ + 1)
res13: scala.collection.SeqView[Int,Seq[_]] = SeqViewM(...)

map 的結果是一個列印 SeqViewM(...) 的值。這基本上是一個封裝器,記錄了需要對向量 v 應用具有函數 (_ + 1)map 的事實。不過,它不會應用該映射,直到檢視被 force 為止。SeqView 之後的「M」表示檢視封裝了一個映射操作。其他字母表示其他延遲操作。例如,「S」表示延遲的 slice 操作,而「R」表示 reverse。現在讓我們將第二個 map 應用於最後一個結果。

scala> res13 map (_ * 2)
res14: scala.collection.SeqView[Int,Seq[_]] = SeqViewMM(...)

現在你會得到一個包含兩個映射操作的 SeqView,因此它會以雙重「M」列印:SeqViewMM(...)。最後,強制最後一個結果會得到

scala> res14.force
res15: Seq[Int] = Vector(4, 6, 8, 10, 12, 14, 16, 18, 20, 22)

兩個儲存的函數都會在 force 操作執行的一部分中應用,並建立一個新的向量。這樣就不需要中間資料結構了。

需要注意的一個細節是,最終結果的靜態類型是 Seq,而不是 Vector。追溯類型,我們會看到,一旦第一個延遲映射被應用,結果就會有靜態類型 SeqViewM[Int, Seq[_]]。也就是說,檢視被應用於特定序列類型 Vector 的「知識」已經遺失了。為某個類別實作檢視需要大量的程式碼,因此 Scala 集合函式庫主要只為一般集合類型提供檢視,而不為特定實作提供檢視(陣列是一個例外:對陣列應用延遲操作將再次產生靜態類型為 Array 的結果)。

您可能會考慮使用檢視的兩個原因。第一個是效能。您已經看到,透過將集合切換為檢視,可以避免建立中間結果。這些節省可能非常重要。另一個範例,考量在字詞清單中尋找第一個迴文。迴文是一個從前往後讀或從後往前讀都一樣的字詞。以下是必要的定義

def isPalindrome(x: String) = x == x.reverse
def findPalindrome(s: Seq[String]) = s find isPalindrome

現在,假設您有一個非常長的字詞序列,而且您想要在該序列的前一百萬個字詞中找到一個迴文。您可以重複使用 findPalindrome 的定義嗎?當然,您可以撰寫

findPalindrome(words take 1000000)

這很好地將取得序列前一百萬個字詞和在其中尋找迴文的兩個面向分開。但是缺點是,它總是會建立一個由一百萬個字詞組成的中間序列,即使該序列的第一個字詞已經是迴文。因此,潛在有 999’999 個字詞會被複製到中間結果中,而完全沒有事後檢查。許多程式設計師會在這裡放棄,並撰寫自己的專門版本,在引數序列的某個給定前綴中尋找迴文。但是有了檢視,您就不必這麼做。只要撰寫

findPalindrome(words.view take 1000000)

這具有相同的良好關注點分離,但它只會建立一個單一的輕量級檢視物件,而不是一百萬個元素的序列。這樣,您就不需要在效能和模組化之間做出選擇。

第二個使用案例適用於可變序列上的檢視。此類檢視上的許多轉換器函數提供一個可讓您更新該序列中某些元素的原始序列視窗。為了在範例中看到這一點,假設您有一個陣列 arr

scala> val arr = (0 to 9).toArray
arr: Array[Int] = Array(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)

您可以透過建立 arr 檢視的切片來建立該陣列的子視窗

scala> val subarr = arr.view.slice(3, 6)
subarr: scala.collection.mutable.IndexedSeqView[
Int,Array[Int]] = IndexedSeqViewS(...)

這會提供一個檢視 subarr,它會參考陣列 arr 中位置 3 到 5 的元素。檢視不會複製這些元素,它只會提供它們的參考。現在,假設您有一個方法會修改序列中的一些元素。例如,下列 negate 方法會否定給定的整數序列的所有元素

scala> def negate(xs: collection.mutable.Seq[Int]) =
         for (i <- 0 until xs.length) xs(i) = -xs(i)
negate: (xs: scala.collection.mutable.Seq[Int])Unit

假設現在您想要否定陣列 arr 中位置 3 到 5 的元素。您能使用 negate 嗎?使用檢視,這很簡單

scala> negate(subarr)
scala> arr
res4: Array[Int] = Array(0, 1, 2, -3, -4, -5, 6, 7, 8, 9)

這裡發生的事情是,negate 變更了 subarr 的所有元素,它原本是陣列 arr 的一個切片。再次,您會看到檢視有助於保持模組化。上述程式碼很好地將要對哪個索引範圍套用方法的問題與要套用哪個方法的問題分開。

在看過檢視的所有這些巧妙用途後,您可能會想,為什麼要嚴格收集?一個原因是效能比較並不總是偏好延遲收集而非嚴格收集。對於較小的收集大小,在檢視中形成和套用封閉的額外開銷通常大於避免中間資料結構所獲得的收益。一個可能更重要的原因是,如果延遲運算有副作用,則檢視中的評估可能會非常混淆。

以下是 Scala 2.8 之前版本的一些使用者遇到的範例。在這些版本中,Range 類型是延遲的,因此它實際上就像檢視一樣。人們嘗試建立許多像這樣的執行緒

val actors = for (i <- 1 to 10) yield actor { ... }

他們驚訝地發現,即使 actor 方法應該建立並從其後方大括號中封閉的程式碼啟動執行緒,但之後沒有任何執行緒正在執行。要解釋為什麼沒有發生任何事,請記住上述 for 運算式等於 map 的應用

val actors = (1 to 10) map (i => actor { ... })

由於先前 (1 to 10) 產生的範圍就像檢視一樣,因此 map 的結果又是檢視。也就是說,沒有計算任何元素,因此沒有建立任何執行緒!透過強制整個運算式的範圍,可以建立執行緒,但這遠遠不足以讓執行緒執行其工作。

為了避免這種意外情況,Scala 2.8 函式庫有更常規的規則。除了串流和檢視外,所有函式庫都是嚴格的。從嚴格函式庫轉換到延遲函式庫的唯一方法是透過 view 方法。唯一的方法是透過 force 返回。因此,上述 actors 定義在 Scala 2.8 中會如預期般運作,它會建立並啟動 10 個 actors。若要回復到令人意外的前一個行為,您必須新增一個明確的 view 方法呼叫

val actors = for (i <- (1 to 10).view) yield actor { ... }

總而言之,檢視是一種強大的工具,可以調和效率與模組化的考量。但為了不陷入延遲評估的層面,您應該將檢視限制在兩個情境。您可以將檢視套用在純函式程式碼中,其中函式庫轉換沒有副作用。或者,您可以將它們套用在可變函式庫中,其中所有修改都是明確執行的。最應該避免的是檢視和建立新函式庫的作業混合,同時也產生副作用。

此頁面的貢獻者