scala里的List/Stream/View机制浅析

来源:互联网 发布:比基尼岛核试验 知乎 编辑:程序博客网 时间:2024/05/22 23:28

List机制浅析

scala里的List就是单向链表,一般通过下面方式来组装一个List:

val l = 1 :: 2 :: 3 :: Nil

Nil是空List,::是右结合操作符,所以上述写法相当于:
Nil.::(3).::(2).::(1)
我们来看看::连接符是如何实现的:

sealed abstract class List[+A] ..... {  ...  def isEmpty: Boolean  def head: A  def tail: List[A]  def ::[B >: A](x: B): List[B] =    new scala.collection.immutable.::(x, this)  .....  }final case class ::[B](override val head: B, private[scala] var tl: List[B]) extends List[B] {  override def tail : List[B] = tl  override def isEmpty: Boolean = false}    

可见List是由Cons节点(即::类的实例)链接而成,每个Cons节点除了包含值(即head),还有一个指向尾List的tail指针。注意::类的类参数列表里val head前加override修饰符是因为::类重载了基类的head方法,在scala里成员方法和成员变量是被一视同仁的,也就是说,定义了一个成员方法后,我们不能再定义一个同名的成员变量。
另外,元素在加入List之前是要立即计算的,什么意思呢,像下面的语句:

val l = 1 :: {println("haha");2} :: {println("hehe");3} :: Nilprintln(l(0))

会输出:
haha
hehe
1
虽然我们只打印第1个元素,但第2、3个元素里的println动作也执行了,说明在List就绪之时,所有元素都必须计算出来。这样的话,List就无法去表示一个无限序列了。要表达一个无限序列,必须用Stream。

Stream机制浅析

Stream的写法是这样的:

val s = 1 #:: {println("haha");2} #:: {println("hehe");3} #:: Stream.emptyprintln(s)

输出:
Stream(1, ?)
说明Stream就绪时,仅有head元素是计算了的,其他元素均是未知。也就是说,元素在加入Stream之前是无需立即计算的,只在要用时才会计算,比如我们这样写:

println(s(1))

输出:
haha
2

那么,Stream是如何实现这种元素的惰性计算机制的呢?来看代码:

class ConsWrapper[A](tl: => Stream[A]) {    /** Construct a stream consisting of a given first element followed by elements     *  from a lazily evaluated Stream.     */    def #::(hd: A): Stream[A] = cons(hd, tl)    ......  }object cons {    /** A stream consisting of a given first element and remaining elements     *  @param hd   The first element of the result stream     *  @param tl   The remaining elements of the result stream     */    def apply[A](hd: A, tl: => Stream[A]) = new Cons(hd, tl)}final class Cons[+A](hd: A, tl: => Stream[A]) extends Stream[A] {    override def isEmpty = false    override def head = hd    @volatile private[this] var tlVal: Stream[A] = _    @volatile private[this] var tlGen = tl _    def tailDefined: Boolean = tlGen eq null    override def tail: Stream[A] = {      if (!tailDefined)        synchronized {          if (!tailDefined) {            tlVal = tlGen()            tlGen = null          }        }      tlVal    }  }  

我们发现,Stream跟List有点类似,也是一个单向链表,head指向元素值,但与List不同的是,tail指针并不指向尾队列,而是指向一个生成尾队列的函数:
tl: => Stream[A]
既然tail传递的是函数,而非尾队列,说明Stream除了首元素外,其他元素都不是立即计算的。
我们再看Cons类的tail方法,这个方法的实现有两个关键点:
1、尾队列的值tlVal是按需计算出来的,见tlVal = tlGen()这一句
2、一旦tlVal计算出来后,再次调用tail,就直接返回tlVal,不会再重复计算,这是通过tailDefined的检查来保证的。这说明,Stream具有记忆能力,可以缓存中间计算结果,以空间换时间。
所以,Stream适合用作无限序列的生成器,且可用于累积计算场景。

scala里的Stream与java8的Stream(流式操作)名字虽同,可却是全然不同的概念,scala里真正与java8的Stream对应的,其实是View。下面我们来分析View。

View机制浅析

我们看一个具体的例子:

@Test  def testView: Unit ={    val l = 0 to 5    println(l.map(x => x * x).zip(10 to 15))    println(l.view.map(x => x * x).zip(10 to 15))  }

输出:
Vector((0,10), (1,11), (4,12), (9,13), (16,14), (25,15))
SeqViewMZ(…)
未调用view的操作序列输出一个Vector,而调用了view的操作序列仅输出一个SeqViewMZ对象(这里的M是Mapped,Z是Zipped的意思),并未真正计算,若要看到view的结果,需调force强制计算:

println(l.view.map(x => x * x).zip(10 to 15).force)

输出:
Vector((0,10), (1,11), (4,12), (9,13), (16,14), (25,15))

我们来看看view及map的实现:

  override def view = new SeqView[A, Repr] {    protected lazy val underlying = self.repr    override def iterator = self.iterator    override def length = self.length    override def apply(idx: Int) = self.apply(idx)  }  override def map[B, That](f: A => B)(implicit bf: CanBuildFrom[This, B, That]): That = {    newMapped(f).asInstanceOf[That]  }  protected def newMapped[B](f: A => B): Transformed[B] = new { val mapping = f } with AbstractTransformed[B] with Mapped[B]  trait Mapped[B] extends Transformed[B] {    protected[this] val mapping: A => B    def foreach[U](f: B => U) {      for (x <- self)        f(mapping(x))    }  }

view中underlying用lazy修饰,确保SeqView对应的真实容器是按需使用的。
map方法在SeqView基础上创建了一个MappedSeqView型实例,该实例所做的事情就是把x => x * x函子保存起来,即val mapping = f这句。MappedSeqView的foreach方法是要立即计算的,我们看到,它针对SeqView集合的每个元素都要先调一次x => x * x,接着才调foreach的f函子,如下面代码所示:

for (x <- self)  f(mapping(x))

再来看zip调用:

override def zip[A1 >: A, B, That](that: GenIterable[B])(implicit bf: CanBuildFrom[This, (A1, B), That]): That = {    newZipped(that).asInstanceOf[That]protected def newZipped[B](that: GenIterable[B]): Transformed[(A, B)] = new { val other = that } with AbstractTransformed[(A, B)] with Zipped[B]    trait Zipped[B] extends Transformed[(A, B)] {    protected[this] val other: GenIterable[B]    def iterator: Iterator[(A, B)] = self.iterator zip other.iterator    final override protected[this] def viewIdentifier = "Z"  }

zip方法在MappedSeqView的基础上又创建了一个ZippedSeqView型实例,该实例将zip的对端序列(这里是10 to 15)缓存到other成员,即val other = that这句。ZippedSeqView的iterator方法则将上一个集合(即MappedSeqView)的迭代器与对端序列(即other成员)的迭代器做zip结合,如下面代码所示:

def iterator: Iterator[(A, B)] = self.iterator zip other.iterator

所以,view的调用过程像这样(foreach最终会转到iterator):
ZippedSeqView.foreach(
zip(other,MappedSeqView.foreach(
map(SeqView.foreach(
underlying.foreach)))))
等价于:
underlying.foreach(zip(other.item, map(underlying.item)))

也就是说,view是将操作累积起来了,它不像非view版本那样会生成临时集合。就我们这个例子而言,非view版本的处理过程是这样的:
Collection(0,1,2,3,4,5) -> Collection(0,1,4,9,16,25) -> Collection((0,10), (1,11), (4,12), (9,13), (16,14), (25,15))
而view版本则是这样的:
Collection(0,1,2,3,4,5) -> Collection((0*0,10), (1*1,11),(2*2,12),(3*3,13),(4*4,14),(5*5,15))
可见非view版本生成了额外的临时集合,且对原始集合(0,1,2,3,4,5)和临时集合(0,1,4,9,16,25)各做了一次遍历,最终生成结果集合((0,10), (1,11), (4,12), (9,13), (16,14), (25,15))。
而view版本由于不依赖临时集合,只需对原始集合(0,1,2,3,4,5)做一次遍历即可生成结果集合((0,10), (1,11), (4,12), (9,13), (16,14), (25,15))。这样的处理在原始集合数据量很大时,能有效节省内存、提升效率。
最后说明一下,view的这种处理方式有一个专有叫法:惰性化计算,什么意思呢?打个比方,就是在我们提交map计算给集合的时候,集合说:“知道了,我等会做”,其实它并没做,只有你真正需要结果时它才会不紧不慢的去执行,此谓之“惰性”。

对比

List与Stream:前者用于有限集合,后者用于无限集合。比如下面代码:

  def constList(n:Int):List[Int] = n :: constList(n)  def constStream(n:Int):Stream[Int] = n #:: constStream(n)  @Test  def testListStream: Unit ={    println(constStream(10))    println(constList(10))  }

println(constStream(10))会输出Stream(10, ?)
而println(constList(10))会栈溢出。

List和View:前者在做集合转换操作(如zip、map、flatMap等)时会生成中间集合,后者则不会,只在集合行为操作(如foreach)时一下子计算出中间累积操作的结果,后者在大集合时更省内存。

Stream和View:两者都会做惰性计算,但关注的维度不一样,前者是集合里元素计算的惰性化,后者则是集合本身计算的惰性化。事实上,Stream是有一个view方法的。

原创粉丝点击