逸言

阅读Scala代码之一

| Comments

学习一门语言,固然需要了解这门语言的语法,但针对一些完全属于不同范式的语言,即使通过阅读书籍可以理解一些特殊的语法,若不能付诸实践,总有隔靴搔痒之感。其实,要能通过运用这门新语言开发一个项目,或能快速并深刻地了解甚至吃透这门语言。我正是这样尝试着运用Scala来开发我的一个开源框架。可是,在开发过程中,我总感觉自己像是被捆绑了一只手的程序员一般,开发过程磕磕碰碰,不够顺畅。仔细想来,还是因为缺乏对这门语言的足够了解,尤其是那些迥异于Java却又在Scala中是极为常见的惯用法,总不能做到在合适的场景信手拈来。

关键在于,自己阅读Scala的代码太少,编写Scala的代码更少。找到症结,那就尝试去解决。当然,我可以选择一些著名的Scala开源框架,例如Play Framework,Kestrel或者Kafka,对其进行深入阅读。可是,我发现这些框架对于目前的我而言,似乎显得困难了一点。那么,就从一些短小的代码段开始着手吧。

今天阅读的这段代码来自Twitter团队编写的Effective Scala。这段代码对一个Seq对象的值进行了分类汇总,然后进行了排序。代码内容如下:

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

首先,它针对Seq对象votes进行了分组,调用了Seq集合的groupBy方法。该方法的定义为:

def groupBy[K](f: (A) => K): immutable.Map[K, Sequ[A]]

该函数的输入参数实际上是一个函数,该函数的参数为A,这个泛型参数在这里指代Seq元素的类型,即一个tuple;返回值为K,为key对应的类型。groupBy函数的返回值是一个不变类型的Map。对于此例而言,就是根据语言进行分类,由此可以得到三个类别,每个类别又包含一个Seq或者List。结果为:

Map(scala -> List((scala,1), (scala,10), (scala,1)), java -> List((java,4)), phthon -> List((phthon,10)))

在这段代码中,用到了Scala的特殊语法,例如groupBy(_._1)。括号中的_代表了一个类型为A的参数,在这里就是tuple对象;而_1则是方法名,对于tuple而言,_1方法能够返回tuple的第一个元素,即语言的名称。(与之类似,_2则会返回tuple的第二个元素。)因此,_._1恰好能够满足groupBy()函数需要传入的函数,从而根据语言的名称对votes进行分组。

紧跟着groupBy函数后面的是一个map函数,它可以通过运用一个函数为Map的所有元素建立一个新的集合。简单地理解,可以将其看做是一种转换操作。在上面给出的代码中,map函数中的case (which, counts) => …是一个模式匹配的匿名函数(Pattern Matching Anonymous Functions)。在《The Scala Language Specification》中对此的定义为:

which appear as an expression without a prior match. The expected type of such an expression must in part be defined. It must be either scala.Functionk[S1, …, Sk, R] for some k > 0, or scala.PartialFunction[S1, R], where the argument type(s) S1, …, Sk must be fully determined, but the result type R may be undetermined.

在文章《Scala Partial Functions Without Phd》中,Erik认为这是一种特殊方式的匿名函数定义,采用这种方式会更加安全,使用更自由。文章给出了一些Partial Function的例子,例如通过Partial Function可以忽略多余的参数,忽略因为除0抛出的异常。这比直接使用匿名函数会更加安全。

在这段代码中,map函数接受which参数就是lang,counts则是lang对应的List。在这个匿名函数中,会对counts这个List类型进行求和操作(通过foldLeft函数)。返回的结果仍然是一个包含了Tuple元素的Map类型。

接下来的方法就比较容易理解了,即调用toSeq将Map转换为Seq,并根据tuple的第二个元素进行排序,此时,排序的关键字为统计的语言次数。soryBy函数的默认排序为升序,因此需要调用reverse颠倒顺序。

如果弄懂了Scala与此相关的语法,要理解这段代码还是比较容易的。然而,在Twitter给出的Effective Scala文章中,提到了关于编程意图的问题。因为上述代码通过一种类似流水线转换的方式完成整个操作,操作过程中的一些中间值被隐藏在一系列的函数调用中,并没有很好地展现其意图。文章提出的解决办法就是声明中间结果和参数。上述代码可以改写为:

val votes = Seq(("scala",1),("java",4),("scala",10),("scala",1),("phthon",10))
val votesByLang = votes groupBy { case(lang, _) => lang }
val sumByLang = votesByLang map { (lang, counts) =>
  val countsOnly = counts map { case(_, count) => count}
  (lang, countsOnly.sum)
}
val orderedVotes = sumByLang.toSeq
  .sortBy { case(_, count) => count }
    .reverse

因为有了中间值的变量声明,意图会变得更清晰一些。我同意这样的观点,特别是针对一些函数式语言或动态语言而言,代码变得简洁了,但有时候会用到一些比较tricky的花招,影响了代码的可读性。但要注意,这种可读性一定是基于该语言的特色而言。我们千万不能将Scala程序写成Java命令式的方式,以为这样适合Java程序员的阅读习惯,这无疑误解了所谓“可读性”的含义。当然,就这段代码而言,由于groupBy函数的名称已经非常清晰,我并不太赞成提取出votesByLang的中间变量。这类似fluent interface的方式,只要API的设计是有意义的,这种流水线的处理方式仍然非常清楚,前提是我们要有合理的排版。

Comments