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

本文深入解析Scala中的List、Stream和View机制,探讨它们在惰性计算、无限序列生成及内存效率方面的特性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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} :: Nil
println(l(0))

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

Stream机制浅析

Stream的写法是这样的:

val s = 1 #:: {println("haha");2} #:: {println("hehe");3} #:: Stream.empty
println(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方法的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值