美文网首页程序员
Scala亮瞎Java的眼

Scala亮瞎Java的眼

作者: _张逸_ | 来源:发表于2016-06-06 13:41 被阅读1525次

    这是我在2015年11月15日成都OpenParty分享的一个题目,确有标题党的嫌疑。Scala自然不是无所不能,Java也没有这么差劲,我只希望给Java程序员提供另外一条可能的选择。在Java 8后,我对Java的怨念已经没有那么强烈了,然而,Scala的优势仍然存在。

    比较Java 8,我重点讲解了Scala的如下优势:

    • 简洁代码
    • 支持OO与FP
    • 高阶函数
    • 丰富的集合操作
    • Stream支持
    • 并发支持

    简洁代码

    Scala提供的脚本特性以及将函数作为一等公民的方式,使得它可以去掉不少在Java中显得冗余的代码,例如不必要的类定义,不必要的main函数声明。Scala提供的类型推断机制,也使得代码精简成为可能。Scala还有一个巧妙的设计,就是允许在定义类的同时定义该类的主构造函数。在大多数情况下,可以避免我们声明不必要的构造函数。

    Scala还提供了一些非常有用的语法糖,如伴生对象,样例类,既简化了接口,也简化了我们需要书写的代码。例如如下代码:

    case class Person(name: String, age: Int)
    val l = List(Person("Jack", 28), Person("Bruce", 30))
    

    这里的List和Person都提供了伴生对象,避免再写冗余的new。这种方式对于DSL支持也是有帮助的。Person是一个样例类,虽然只有这么一行代码,蕴含的含义却非常丰富——它为Person提供了属性,属性对应的访问器,equals和hashcode方法,伴生对象,以及对模式匹配的支持。在Scala 2.11版本中,还突破了样例类属性个数的约束。由于样例类是不变的,也能实现trait,因而通常作为message而被广泛应用到系统中。例如在AKKA中,actor之间传递的消息都应该尽量定义为样例类。

    支持OO与FP

    将面向对象与函数式编程有机地结合,本身就是Martin Odersky以及Scala的目标。这二者的是非,我从来不予以置评。个人认为应针对不同场景,选择不同的设计思想。基于这样的思想,Scala成为我的所爱,也就是顺其自然的事情了。

    演讲中,我主要提及了纯函数的定义,并介绍了应该如何设计没有副作用的纯函数。纯函数针对给定的输入,总是返回相同的输出,且没有任何副作用,就使得纯函数更容易推论(这意味着它更容易测试),更容易组合。从某种角度来讲,这样的设计指导思想与OO阵营中的CQS原则非常一致,只是重用的粒度不一样罢了。

    我给出了Functional Programming in Scala一书中的例子。如下代码中的declareWinner函数并非纯函数:

    object Game {
      def printWinner(p: Player): Unit =
        println(p.name + " is the winner!")
    
      def declareWinner(p1: Player, p2: Player): Unit =
        if (p1.score > p2.score)
          printWinner(p1)
        else printWinner(p2)
    }
    

    这里的printWinner要向控制台输出字符串,从而产生了副作用。(简单的判断标准是看函数的返回值是否为Unit)我们需要分离出专门返回winner的函数:

    def winner(p1: Player, p2: Player): Player =
        if (p1.score > p2.score) p1 else p2
    

    消除了副作用,函数的职责变得单一,我们就很容易对函数进行组合或重用了。除了可以打印winner之外,例如我们可以像下面的代码那样获得List中最终的获胜者:

    val players = List(Player("Sue", 7), Player("Bob", 8), Player("Joe", 4))
    val finalWinner = players.reduceLeft(winner)
    

    函数的抽象有时候需要脑洞大开,需要敏锐地去发现变化点与不变点,然后提炼出函数。例如,当我们定义了这样的List之后,比较sum与product的异同:

    sealed trait MyList[+T]
    case object Nil extends MyList[Nothing]
    case class Cons[+T](h: T, t: MyList[T]) extends MyList[T]
    
    object MyList {
      def sum(ints: MyList[Int]):Int = ints match {
        case Nil => 0
        case Cons(h, t) => h + sum(t)
      }
    
      def product(ds: MyList[Double]):Double = ds match {
        case Nil => 1.0
        case Cons(h, t) => h * product(t)
      }
    
      def apply[T](xs: T*):MyList[T] =
        if (xs.isEmpty) Nil
        else Cons(xs.head, apply(xs.tail: _*))
    }
    

    sum与product的相同之处都是针对List的元素进行运算,运算规律是计算两个元素,将结果与第三个元素进行计算,然后依次类推。这就是在函数式领域中非常常见的折叠(fold)计算:

    def foldRight[A, B](l: MyList[A], z: B)(f: (A, B) => B):B = l match {
        case Nil => z
        case Cons(x, xs) => f(x, foldRight(xs, z)(f))
    }
    

    在引入了foldRight函数后,sum和product就可以重用foldRight了:

      def sum(ints: MyList[Int]):Int = foldRight(ints, 0)(_ + _)
      def product(ds: MyList[Double]):Double = foldRight(ds, 0.0)(_ * _)
    

    在函数式编程的世界里,事实上大多数数据操作都可以抽象为filter,map,fold以及flatten几个操作。查看Scala的集合库,可以验证这个观点。虽然Scala集合提供了非常丰富的接口,但其实现基本上没有超出这四个操作的范围。

    高阶函数

    虽然Java 8引入了简洁的Lambda表达式,使得我们终于脱离了冗长而又多重嵌套的匿名类之苦,但就其本质,它实则还是接口,未能实现高阶函数,即未将函数视为一等公民,无法将函数作为方法参数或返回值。例如,在Java中,当我们需要定义一个能够接收lambda表达式的方法时,还需要声明形参为接口类型,Scala则省去了这个步骤:

    def find(predicate: Person => Boolean)
    

    结合Curry化,还可以对函数玩出如下的魔法:

    def add(x: Int)(y: Int) = x + y
    val addFor = add(2) _
    val result = addFor(5)
    

    表达式add(2) _返回的事实上是需要接受一个参数的函数,因此addFor变量的类型为函数。此时result的结果为7。

    当然,从底层实现来看,Scala中的所有函数其实仍然是接口类型,可以说这种高阶函数仍然是语法糖。Scala之所以能让高阶函数显得如此自然,还在于它自己提供了基于JVM的编译器。

    丰富的集合操作

    虽然集合的多数操作都可以视为对foreach, filter, map, fold等操作的封装,但一个具有丰富API的集合库,却可以让开发人员更加高效。例如Twitter给出了如下的案例,要求从一组投票结果(语言,票数)中统计不同程序语言的票数并按照得票的顺序显示:

      val votes = Seq(("scala", 1), ("java", 4), ("scala", 10), ("scala", 1), ("python", 10))
      val orderedVotes = votes
        .groupBy(_._1)
        .map { case (which, counts) =>
        (which, counts.foldLeft(0)(_ + _._2))
      }.toSeq
        .sortBy(_._2)
        .reverse
    

    这段代码首先将Seq按照语言类别进行分组。分组后得到一个Map[String, Seq[(Stirng, Int)]]类型:

    scala.collection.immutable.Map[String,Seq[(String, Int)]] = Map(scala -> List((scala,1), (scala,10), (scala,1)), java -> List((java,4)), python -> List((python,10)))
    

    然后将这个类型转换为一个Map。转换时,通过foldLeft操作对前面List中tuple的Int值累加,所以得到的结果为:

    scala.collection.immutable.Map[String,Int] = Map(scala -> 12, java -> 4, python -> 10)
    

    之后,将Map转换为Seq,然后按照统计的数值降序排列,接着反转顺序即可。

    显然,这些操作非常适用于数据处理场景。事实上,Spark的RDD也可以视为一种集合,提供了比Scala更加丰富的操作。此外,当我们需要编写这样的代码时,还可以在Scala提供的交互窗口下对算法进行spike,这是目前的Java所不具备的。

    Stream

    Stream与大数据集合操作的性能有关。由于函数式编程对不变性的要求,当我们操作集合时,都会产生一个新的集合,当集合元素较多时,会导致大量内存的消耗。例如如下的代码,除原来的集合外,还另外产生了三个临时的集合:

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

    比较对集合的while操作,这是函数式操作的缺陷。虽可换以while来遍历集合,却又丢失了函数的高阶组合(high-level compositon)优势。

    解决之道就是采用non-strictness的集合。在Scala中,就是使用stream。关于这部分内容,崔鹏飞已有文章《Scala中Stream的应用场景及其实现原理》作了详细叙述。

    并发与并行

    Scala本身属于JVM语言,因此仍然支持Java的并发处理方式。若我们能遵循函数式编程思想,则建议有效运用Scala支持的并发特性。由于Scala在2.10版本中将原有的Actor取消,转而使用AKKA,所以我在演讲中并没有提及Actor。这是另外一个大的话题。

    除了Actor,Scala中值得重视的并发特性就是Future与Promise。默认情况下,future和promise都是非阻塞的,通过提供回调的方式获得执行的结果。future提供了onComplete、onSuccess、onFailure回调。如下代码:

      println("starting calculation ...")
    
      val f = Future {
        sleep(Random.nextInt(500))
        42
      }
    
      println("before onComplete")
      f.onComplete {
        case Success(value) => println(s"Got the callback, meaning = $value")
        case Failure(e) => e.printStackTrace
      }
    
      // do the rest of your work
      println("A ..."); sleep(100)
      println("B ..."); sleep(100)
      println("C ..."); sleep(100)
      println("D ..."); sleep(100)
      println("E ..."); sleep(100)
      println("F ..."); sleep(100)
    
      sleep(2000)
    

    f的执行结果可能会在打印A到F的任何一个时间触发onComplete回调,以打印返回的结果。注意,这里的f是Future对象。

    我们还可以利用for表达式组合多个future,AKKA中的ask模式也经常采用这种方式:

    object Cloud {
        def runAlgorithm(times: Int): Future[Int] = Future {
          Thread.sleep(times)
          times
        }
    }
    object CloudApp extends App {
      val result1 = Cloud.runAlgorithm(10)  //假设runAlgorithm需要耗费较长时间
      val result2 = Cloud.runAlgorithm(20)
      val result3 = Cloud.runAlgorithm(30)
    
      val result = for {
        r1 <- result1
        r2 <- result2
        r3 <- result3
      } yield (r1 + r2 + r3)
    
      result onSuccess {
        case result => println(s"total = $result")
      }
    
      Thread.sleep(2000)
    }
    

    这个例子会并行的执行三个操作,最终需要的时间取决于耗时最长的操作。注意,yield返回的仍然是一个future对象,它持有三个future结果的和。

    promise相当于是future的工厂,只是比单纯地创建future具有更强的功能。这里不再详细介绍。

    Scala提供了非常丰富的并行集合,它的核心抽象是splitter与combiner,前者负责分解,后者就像builder那样将拆分的集合再进行合并。在Scala中,几乎每个集合都对应定义了并行集合。多数情况下,可以调用集合的par方法来创建。

    例如,我们需要抓取两个网站的内容并显示:

    val urls = List("http://scala-lang.org",
      "http://agiledon.github.com")
    
    def fromURL(url: String) = scala.io.Source.fromURL(url).getLines().mkString("\n")
    
    val t = System.currentTimeMillis()
    urls.par.map(fromURL(_))
    println
    println("time: " + (System.currentTimeMillis - t) + "ms")
    

    如果没有添加par方法,程序就会顺序抓取两个网站内容,效率差不多会低一半。

    那么,什么时候需要将集合转换为并行集合呢?这当然取决于集合大小。但这并没有所谓的标准值。因为影响执行效率的因素有很多,包括CPU的类型、核数、JVM的版本、集合元素的workload、特定操作、以及内存管理等。

    并行集合会启动多个线程来执行,默认情况下,会根据cpu核数以及jvm的设置来确定。如果有兴趣,可以选择两台cpu核数不同的机器分别运行如下代码:

    (1 to 10000).par.map(i => Thread.currentThread.getName).distinct.size
    

    这段代码可以获得线程的数量。

    我在演讲时,有人提问这种线程数量的灵活判断究竟取决于编译的机器,还是运行的机器?答案是和运行的机器有关。这事实上是由JVM的编译原理决定的。JVM的编译与纯粹的静态编译不同,Java和Scala编译器都是将源代码转换为JVM字节码,而在运行时,JVM会根据当前运行机器的硬件架构,将JVM字节码转换为机器码。这就是所谓的JIT(just-in-time)编译。

    Scala还有很多优势,包括模式匹配、隐式转换、类型类、更好的泛型协变逆变等,当然这些特性也是造成Scala变得更复杂的起因。我们需要明智地判断,控制自己卖弄技巧的欲望,在代码可读性与高效精简之间取得合理的平衡。

    题外话

    说些题外话,当我推荐Scala时,提出质疑最多的往往不是Java程序员,而是负责团队的管理者,尤其是略懂技术或者曾经做过技术的管理者。他们会表示这样那样的担心,例如Scala的编译速度慢,调试困难,学习曲线高,诸如此类。

    编译速度一直是Scala之殇,由于它相当于做了两次翻译,且需要对代码做一些优化,这个问题一时很难彻底根治。

    调试困难被吐槽得较激烈,这是因为Scala的调试信息总是让人难以定位。虽然在2.9之后,似乎已有不少改进,但由于类型推断等特性的缘故,相较Java而言,打印的栈信息仍有词不达意之处。曲线救国的方式是多编写小的、职责单一的类(尤其是trait),尽量编写纯函数,以及提高测试覆盖率。此外,调试是否困难还与开发者自身对于Scala这门语言的熟悉程度有关,不能将罪过一味推诿给语言本身。

    至于学习曲线高的问题,其实还在于我们对Scala的定位,即确定我们是开发应用还是开发库。此外,对于Scala提供的一些相对晦涩难用的语法,我们尽可以不用。ThoughtWorks技术雷达上将“Scala, the good parts”放到Adopt,而非整个Scala,寓意意味深长。

    通常而言,OO转FP会显得相对困难,这是两种根本不同的思维范式。张无忌学太极剑时,学会的是忘记,只取其神,我们学FP,还得尝试忘记OO。自然,学到后来,其实还是万法归一。OO与FP仍然有许多相同的设计原则,例如单一职责,例如分而治之。

    对于管理者而言,最关键的一点是明白Scala与Java的优劣对比,然后根据项目情况和团队情况,明智地进行技术决策。我们不能完全脱离上下文去说A优于B。世上哪有绝对呢?

    相关文章

      网友评论

        本文标题:Scala亮瞎Java的眼

        本文链接:https://www.haomeiwen.com/subject/ahardttx.html