过时通知
集合有很多用于构建新集合的方法。示例包括 map
、filter
或 ++
。我们称此类方法为转换器,因为它们至少将一个集合作为其接收器对象,并将其结果生成另一个集合。
实现转换器有两种主要方法。一种是严格的,即转换器会生成一个包含所有元素的新集合。另一种是非严格的或惰性的,即只为结果集合构建一个代理,并且仅在需要时才构建其元素。
以下惰性映射操作实现就是一个非严格转换器的示例
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
方法。
我们来看一个示例。假设你有一个 Int 向量,你希望连续映射两个函数
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)
的组合进行一次映射会更快。如果你在同一位置可以使用这两个函数,则可以手动执行此操作。但通常,数据结构的连续转换是在不同的程序模块中完成的。然后,融合这些转换会破坏模块化。避免中间结果的更通用方法是先将向量转换为视图,然后将所有转换应用于视图,最后强制视图为向量
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
的所有元素,而 subarr
最初是数组 arr
的切片。同样,您会看到视图有助于保持事物的模块化。上面的代码很好地将应用方法的索引范围问题与应用什么方法的问题分开了。
在了解了视图的所有这些巧妙用途之后,您可能会想为什么还要使用严格集合?一个原因是,性能比较并不总是支持延迟集合优于严格集合。对于较小的集合大小,在视图中形成和应用闭包的额外开销通常大于避免中间数据结构的收益。一个可能更重要的原因是,如果延迟操作有副作用,则视图中的求值可能会非常混乱。
这里有一个示例,它困扰了 2.8 之前的 Scala 版本的一些用户。在这些版本中,Range 类型是惰性的,因此它实际上表现得像一个视图。人们尝试像这样创建一些 actor
val actors = for (i <- 1 to 10) yield actor { ... }
他们惊讶地发现,之后没有一个 actor 被执行,即使 actor 方法应该从紧随其后的花括号中的代码创建并启动一个 actor。为了解释为什么什么也没有发生,请记住上面的 for 表达式等效于 map 的应用
val actors = (1 to 10) map (i => actor { ... })
由于之前由 (1 to 10)
生成的范围表现得像一个视图,因此 map 的结果又是一个视图。也就是说,没有计算任何元素,因此没有创建任何 actor!通过强制整个表达式的范围可以创建 actor,但显然这不是让 actor 执行其工作所必需的。
为了避免这样的意外,Scala 2.8 集合库具有更规则的规则。除了流和视图之外,所有集合都是严格的。从严格集合到惰性集合的唯一方法是通过 view
方法。返回的唯一方法是通过 force
。因此,上面的 actors
定义在 Scala 2.8 中的行为将按预期的那样,即创建并启动 10 个 actor。要恢复之前的意外行为,您必须添加一个显式的 view
方法调用
val actors = for (i <- (1 to 10).view) yield actor { ... }
总之,视图是一个强大的工具,可以协调效率问题和模块化问题。但为了不纠缠于延迟评估的各个方面,您应该将视图限制在两种情况下。您可以在纯函数代码中应用视图,其中集合转换没有副作用。或者您可以在可变集合上应用它们,其中所有修改都是显式完成的。最好避免的是视图和操作的混合,这些视图和操作创建了新集合,同时还具有副作用。