Scala函数式编程(六) 懒加载与Stream


前情提要

什么时候效率复习最高,毫无疑问是考试前的最后一夜,同样的道理还有寒暑假最后一天做作业最高效。学界有一个定理:deadline是第一生产力,说的就是这个事情。

同样的,这个道理完全可以推广到函数式编程中来,而懒加载(scala的lazy关键字)就是这样的东西。

在函数式编程中,因为要维持不变性,故而需要更多的存储空间,这一点在函数式数据结构中有说到。懒加载可以说会在一定程度上解决这个问题,同时通过缓存数据还能提高一些运行效率,以及通过面向表达式编程提高系统的模块化。

这一节先介绍lazy的具体内容,及其好处,然后通过Stream这一数据结构讨论懒加载更多应用场景以及懒加载是如何实现性能优化的。

1.scala懒加载lazy

1.1 什么是懒加载

懒加载,顾名思义就是一个字懒。就像老板让你去干活,刚叫的时候你不会去干,只有等到着急的时候,催你的时候你才会去干。懒加载就是这样的东西。

我们直接用命令行测试下:

//右边是一个表达式,这里不是懒加载,直接求值
scala> val x = { println("x"); 15 }
x
x: Int = 15

//使用了懒加载,这里和上面的右侧是类似的,不过不会立即求值
scala> lazy val y = { println("y"); 13 }
y: Int = 

//x的值变成15,也就是表达式的结果
scala> x
res2: Int = 15

//懒加载在真正调用的时候,才运行表达式的内容,打印y,并返回值
scala> y
y
res3: Int = 13

//lazy已经缓存的表达式的内容,所以不会再运行表达式里面的东西,也就是表达式内容只运行一次
scala> y
res4: Int = 13

看上面代码就明白了,懒加载就是让表达式里面的计算延迟,并且只计算一次,然后就会缓存结果。

值得一提的是,懒加载只对表达式和函数生效,如果直接定义变量,那是没什么用的。因为懒加载就是让延迟计算,你直接定义变量那计算啥啊。。。

说完lazy这个东西,那就来说说它究竟有什么用。

1.2 懒加载的好处

初次看到这个东西,会疑惑,懒加载有什么用?其实它的用处可不小。

lazy的一个作用,是将推迟复杂的计算,直到需要计算的时候才计算,而如果不使用,则完全不会进行计算。这无疑会提高效率。

而在大量数据的情况下,如果一个计算过程相互依赖,就算后面的计算依赖前面的结果,那么懒加载也可以和缓存计算结合起来,进一步提高计算效率。嗯,有点类似于spark中缓存计算的思想。

除了延迟计算,懒加载也可以用于构建相互依赖或循环的数据结构。我这边再举个从stackOverFlow看到的例子:

这种情况会出现栈溢出,因为无限递归,最终会导致堆栈溢出。

trait Foo { val foo: Foo }
case class Fee extends Foo { val foo = Faa() }
case class Faa extends Foo { val foo = Fee() }

println(Fee().foo)
//StackOverflowException

而使用了lazy关键字就不会了,因为经过lazy关键字修饰,变量里面的内容压根就不会去调用。

trait Foo { val foo: Foo }
case class Fee extends Foo { lazy val foo = Faa() }
case class Faa extends Foo { lazy val foo = Fee() }

println(Fee().foo)
//Faa()

当然上面这种方法也可以让它全部求值,在后面stream的时候再介绍。

1.3 其他语言的懒加载

看起来懒加载是很神奇的东西,但其实这个玩意也不是什么新鲜东西。一说你可能就会意识到了,其实懒加载就是单例模式中的懒汉构造法。

以下是scala中的懒加载:

class LazyTest {
  //懒加载定义一个变量
  lazy val msg = "Lazy"
}

如果转成同样功能的java代码:

class LazyTest {
  public int bitmap$0;
  private String msg;

  public String msg() {
    if ((bitmap$0 & 1) == 0) {
        synchronized (this) {
            if ((bitmap$0 & 1) == 0) {
                synchronized (this) {
                    msg = "Lazy";
                }
            }
            bitmap$0 = bitmap$0 | 1;
        }
    }
    return msg;
  }

}

其实说白了,就是考虑多线程情况下,运用懒汉模式创建一个单例的代码。只不过在scala中,提供了语法级别的支持,所以懒加载使用起来更加方便。

OK,介绍完懒加载,我们再说说一个息息相关的数据结构,Stream(流)。

2.Stream数据结构

Stream数据结构,根据名字判断,就知道这是一个流。直观得说,Stream可以看作一个特殊点的List,特殊在于Stream天然就是“懒”的(java8也新增了叫Stream的数据结构,但和scala的还是有点区别的,这一点要区分好)。

直接看代码吧:

//新建List
scala> val li = List(1,2,3,4,5)
li: List[Int] = List(1, 2, 3, 4, 5)

//新建Stream
scala> val stream = Stream(1,2,3,4,5)
stream: scala.collection.immutable.Stream[Int] = Stream(1, ?)

//每个Stream有两个元素,一个head表示当前元素,tail表示除当前元素后面的其他元素,也可能为空
//就跟链表一样
scala> stream.head
res21: Int = 1

//后一个元素,类似链表
scala> stream.tail
res20: scala.collection.immutable.Stream[Int] = Stream(2, ?)

List可以直接转成Stream,也可以新生成,一个Stream和链表是类似的,有一个当前元素,和一个指向下一个元素的句柄。

但是!Stream不会计算,或者说获取下一个元素的状态和内容。也就是说,在真正调用前,当前是Stream是不知道它指向下一个元素究竟是什么,是不是空的?

那么问题来了,为嘛要大费周章搞这么个Stream?

其实Stream可以做很多事情,这里简单介绍一下。首先说明,无论是懒加载还是Stream,使用它们很大程度是为了提高运行效率或节省空间。

获取数据

Stream特别适合在不确定量级的数据中,获取满足条件的数据。这里给出一个大佬的例子:
Scala中Stream的应用场景及事实上现原理

这个例子讲的是在50个随机数中,获取前3个能被整除的数字。当然直接写个while很简单,但如果要用函数式的方式就不容易了。

而如果要没有一丝一毫的空间浪费,那就只有使用Stream了。

再举个例子,如果要读取一个非常大的文件,要读取第一个'a'字符前面的所有数据。

如果使用getLine或其他iterator的api,那要用循环或递归迭代去获取,而如果用Stream,只需一行代码。

Source.fromFile("path").toStream.takeWhile(_ != 'a')

道理和随机数的那个例子是一样的。

消除中间结果

这是《scala函数式编程》书里面的例子,这里拿来说一说。

有这样一行代码:

List(1,2,3,4).map(_ + 10).filter(_ % 2 == 0).map(_ * 3)

如果让它执行,那么会先执行map方法,生成一个中间结果,再执行filter,返回一个中间结果,再执行map得到最终结果,流程大概如下:

List(1,2,3,4).map(_ + 10).filter(_ % 2 == 0).map(_ * 3) => 
//生成中间结果
List(11,12,13,14).filter(_ % 2 == 0).map(_ * 3) => //又生成中间结果
List(12,14).map(_ * 3) =>
//得到最终结果
List(36,42)

看,上面例子中,会生成多个中间的List,但其实这些是没必要的,我们完全能重写一个While,直接在一个代码块中实现map(_ + 10).filter(_ % 2 == 0).map(_ * 3)这三个函数的功能,但却不够优雅。而Stream能够无缝做到这点。

可以在idea中用代码调试功能追踪一下,因为Stream天生懒的原因,它会让一个元素直接执行全部函数,第一个元素产生结果后,再执行下一个元素,避免中间临时数据产生。看流程:

Stream(1,2,3,4).map(_ + 10).filter(_ % 2 == 0).toList =>
//对第一个元素应用map
Stream(11,Stream(2,3,4)).map(_ + 10).filter(_ % 2 == 0).toList =>
//对第一个元素应用filter
Stream(2,3,4).map(_ + 10).filter(_ % 2 == 0).toList  =>
//对第二个元素应用map
Stream(12,Stream(3,4)).map(_ + 10).filter(_ % 2 == 0).toList
//对第二个元素应用filter生成结果
12 :: Stream(3,4).map(_ + 10).filter(_ % 2 == 0).toList  =>

......以此类推

通过Stream数据结构,可以优雅得去掉临时数据所产生的负面影响。

小结

总而言之,懒加载主要是为了能够在一定程度上提升函数式编程的效率,无论是空间效率还是时间效率。这一点看Stream的各个例子就明白了,Stream这种数据结构天然就是懒的。

同时懒加载更重要的一点是通过分离表达式和值,提升了模块化。这句话听起来比较抽象,还是得看回1.2 懒加载的好处这一节的例子。所谓值和表达式分离,在这个例子中,就是当调用Fee().foo的时候,不会立刻要求得它的值,而只是获得了一个表达式,表达式的值暂时并不关心。这样就将表达式和值分离开来,并且模块化特性更加明显!从这个角度来看,这一点和介绍的Try()错误处理有些类似,都是关注表达式而不关注具体的值,其核心归根结底就是为了提升模块化

以上~