迄今为止,在此 系列 中,我们已经讨论了 Scala 对生态环境的保真度,展示了 Scala 如何将众多的 Java 核心对象功能合并在一起。如果 Scala 只是编写对象的另一种方式,那么它不会有任何引人注意的地方,或者说不再那么功能强大。Scala 的函数概念和对象概念的合并,以及它对编程人员效率的重视,这些使得学习 Scala 语言比 Java-cum-Scala 编程人员所想象的体验更加复杂、更加微妙。
例如,对控制结构(比如 if
、while
和 for
)使用 Scala 的方法。尽管这些控制结构看起来类似一些老的、还比较不错的 Java 结构,但实际上 Scala 为它们增加了一些完全不同的特性。本月的文章是关于使用 Scala 控制结构时能够期望获得哪些东西的入门级读物,而不是在制造许多错误(并编写一堆错误代码)之后,让您冒着遭受挫折的风险去寻找差异。
在 本系列的上一篇文章 中,可以了解到 Scala 能够通过定义一些方法来定义 POJO,这些方法模仿基于 POJO 的环境所需的传统 “getter 和 setter”。在这篇文章发表之后,我收到了 Bill Venners 发来的电子邮件,Bill Venners 是即将发表的正式的 Scala 参考资料使用 Scala 编程(请参阅 参考资料)的合著者之一。Bill 指出了实现上述操作的一个更简单的方法,即使用 scala.reflect.BeanProperty
标注,如下所示:
清单 1 中的方法(上一篇文章 中的清单 13 的修订版)为指定的 var
生成了 get/set
方法对。惟一的缺陷是这些方法并不实际存在于 Scala 代码中,因此其他 Scala 代码无法调用它们。这通常不是什么大问题,因为 Scala 将对为自己生成的字段使用已生成的方法;如果事先不知道,那么这些对您而言可能是一个惊喜。
在查看了清单 1 中的代码之后,最让我感到震动的是,Scala 并没有只演示组合函数概念和对象概念的强大威力,它还演示了自 Java 首次发布之后的 30 年里对象语言带来的一些益处。
您将看到的许多奇怪的、不可思议的东西都可以归功于 Scala 的函数特性,因此,简单介绍一下函数语言开发和演变的背景可能非常有用。
在函数语言中,将越来越高级的结构直接构建到语言中是不常见的。此外,语言是通过一组核心原语结构定义的。在与将函数作为对象传递的功能结合之后,可用来定义功能的高阶函数 看起来 像是超出了核心语言的范围,但实际上它只是一个库。类似于任何库,此功能可以替换、扩充或扩展。
根据一组核心原语构建语言的合成 特性由来已久,可以追溯到 20 世纪 60 年代和 70 年代使用 Smalltalk、Lisp 和 Scheme 的时候。诸如 Lisp 和 Scheme 之类的语言因为它们在更低级别的抽象上定义更高级别抽象的能力而受到人们的狂热追捧。编程人员可以使用高级抽象,用它们构建更高级的抽象。如今听到讨论这个过程时,它通常是关于特定于域的语言(或 DSL)的(请参阅 参考资料)。实际上,它只是关于如何在抽象之上构建抽象的过程。
在 Java 语言中,惟一选择就是利用 API 调用完成此操作;在 Scala 中,可以通过扩展语言本身实现它。试图扩展 Java 语言会带来创建极端场景(corner case)的风险,这些场景将威胁全局的稳定性。而试图扩展 Scala 则只意味着创建一个新库。
我们将从传统的 if
结构开始 —— 当然,此结构必须是最容易处理的结构之一,不是吗?毕竟,从理论上说,if
只检查一个条件。如果条件为真,则执行后面跟着的代码。
但是,这种简单性可能带有欺骗性。传统上,Java 语言对 if
的 else
子句的使用是随意的,并且假定如果条件出错,可以只跳过代码块。但在函数语句中,情况不是这样。为了保持函数语句的算术特性,所有一切都必须以表达式计算的方式出现,包括 if
子句本身(对于 Java 开发人员,这正是三元操作符 —— ?:
表达式 —— 的工作方式)。
在 Scala 中,非真代码块(代码块的 else
部分)必须以与 if
代码块中值种类相同的形式呈现,并且必须产生同一种类的值。这意味着不论以何种方式执行代码,总会产生一个值。例如,请参见以下 Java 代码:
// This is Java String filename = "default.properties"; if (options.contains("configFile")) filename = (String)options.get("configFile"); |
因为 Scala 中的 if
结构自身就是一个表达式,所以重写上述代码会使它们成为清单 3 中所示的更正确的代码片段:
// This is Scala val filename = if (options.contains("configFile")) options.get("configFile") else "default.properties" |
|
尽管真正的赢家是 Scala,但可以通过编写代码将结果分配给 val
,而不是 var
。在设置之后,就无法对 val
进行更改,这与 Java 语言中 final
变量的操作方式是相同的。不可变本地变量最显著的副作用是很容易实现并发性。试图用 Java 代码实现同样的操作时,会带来许多不错的、易读的好代码,如清单 4 中所示:
//This is Java final String filename = options.contains("configFile") ? options.get("configFile") : "default.properties"; |
用代码评审解释这一点可能需要点技巧。也许这样做是正确的,但许多 Java 编程人员会不以为然并且询问 “您做那个干什么”?
接下来,让我们来看一下 while
及其同胞 do-while
。它们做的基本上是同一件事:测试一个条件,如果该条件为真,则继续执行提供的代码块。
通常,函数语言会避开 while
循环,因为 while
实现的大多数操作都可以使用递归来完成。函数语言真地非常类似于 递归。例如,可以考虑一下 “Scala by Example”(请参阅 参考资料)中展示的 quicksort
实现,该实现可以与 Scala 实现一起使用:
不必深入太多的细节,就可以了解 while
循环的用法,它是通过数组中的各种元素进行迭代的,先找到一个支点,然后依次对每个子元素进行排序。毫不令人奇怪的是,while
循环也需要一组可变本地变量,在这里,这些变量被命名为 a 和 b,其中存储的是当前支点。注意,此版本甚至可以在循环自身中使用递归,两次调用循环本身,一次用于对列表左手边的内容进行排序,另一次对列表右手边的内容进行排序。
这足以说明清单 5 中的 quicksort
真的不太容易读取,更不用说理解它。现在来考虑一下 Scala 中的直接 等同物(这意味着该版本与上述版本尽量接近):
清单 6 中的代码看起来非常接近于 Java 版。也就是说,该代码很长,很难看,并且难以理解(特别是并发性那一部分),明显不具备 Java 版的一些优点。
So, I'll improve it ...
//This is Scala
def sort(xs: Array[Int]): Array[Int] =
if (xs.length <= 1) xs
else {
val pivot = xs(xs.length / 2)
Array.concat(
sort(xs filter (pivot >)),
xs filter (pivot ==),
sort(xs filter (pivot <)))
}
显然,清单 7 中的 Scala 代码更简单一些。注意递归的使用,避免完全 while
循环。可以对 Array
类型使用 filter
函数,从而对其中的每个元素应用 “greater-than”、“equals” 和 “less-than” 函数。事实上,在引导装入程序之后,因为 if
表达式是返回某个值的表达式,所以从 sort()
返回的是 sort()
的定义中的(单个)表达式。
简言之,我已经将 while
循环的可变状态完全再次分解为传递给各种 sort()
调用的参数 —— 许多 Scala 狂热爱好者认为这是编写 Scala 代码的正确方式。
可能值得一提的是,Scala 本身并不介意您是否使用 while
代替迭代 —— 您会看到来自编译器的 “您在干什么,在做蠢事吗?” 的警告。Scala 也不会阻止您在可变状态下编写代码。但是,使用 while
或可变状态意味着牺牲 Scala 语言的另一个关键方面,即鼓励编写具有良好并行性的代码。只要有可能并且可行,“Scala 式作风” 会建议您优先在命令块上执行递归。
我想走捷径来讨论一下 Scala 的控制结构,做一些大多数 Java 开发人员根本无法相信的事 —— 创建自己的语言结构。
那些通过死读书学习语言的书呆子会发现一件有趣的事:while
循环(Scala 中的一个原语结构)可能只是一个预定义函数。Scala 文档以及假设的 “While
” 定义中对此进行了解释说明:
// This is Scala def While (p: => Boolean) (s: => Unit) { if (p) { s ; While(p)(s) } } |
上述语句指定了一个表达式,该表达式产生了一个布尔值和一个不返回任何结果的代码块(Unit
),这正是 while
所期望的。
扩展这些代码行很容易,并且可以根据需要使用它们,只需导入正确的库即可。正如前面提到的,这是构建语言的综合方法。在下一节介绍 try
结构的时候,请将这一点牢记于心。
try
结构允许编写如下所示代码:
// This is Scala val url = try { new URL(possibleURL) } catch { case ex: MalformedURLException => new URL("www.tedneward.com") } |
清单 8 中的代码与 清单 2 或 清单 3 中 if
示例中的代码相差甚远。实际上,它比使用传统 Java 代码编写更具技巧,特别是在您想捕获不可变位置上存储的值的时候(正如我在 清单 4 中最后一个示例中所做的那样)。这是 Scala 的函数特性的又一个优点!
清单 8 中所示的 case ex:
语法是另一个 Scala 结构(匹配表达式)的一部分,该表达式用于 Scala 中的模式匹配。我们将研究模式匹配,这是函数语言的一个常见特性,稍后将介绍它;现在,只把它看作一个将用于 switch/case
的概念,那么哪种 C 风格的 struct
将用于类呢?
现在,再来考虑一下异常处理。众所周知,Scala 支持异常处理是因为它是一个表达式,但开发人员想要的是处理异常的标准方法,并不仅仅是捕获异常的能力。在 AspectJ 中,是通过创建方面(aspect)来实现这一点的,这些方面围绕代码部分进行联系,它们是通过切入点定义的,如果想让数据库的不同部分针对不同种类异常采取不同行为,那么必须小心编写这些切入点 —— SQLExceptions
的处理应该不同于 IOExceptions
的处理,依此类推。
在 Scala 中,这只是微不足道的细节。请留神观察!
与前面讨论过的 While
结构类似,tryWithLogging
代码只是来自某个库的函数调用(在这里,是来自同一个类)。可以在适当的地方使用不同的主题变量,不必编写复杂的切入点代码。
此方法的优点在于它利用了 Scala 的捕获一级结构中横切逻辑的功能 —— 以前只有面向方面的人才能对此进行声明。清单 9 中的一级结构捕获了一些异常(经过检查的和未经检查的都包括)并以特定方式进行处理。上述想法的副作用非常多,惟一的限制也许就是想象力了。您只需记得 Scala 像许多函数语言一样允许使用代码块(aka 函数)作为参数并根据需要使用它们即可。
所有这些都引导我们来到了 Scala 控制结构套件的实际动力源泉:for
结构。该结构看起来像是 Java 的增强 for
循环的简单早期版,但它远比一般的 Java 编程人员开始设想的更强大。
让我们来看一下 Scala 如何处理集合上的简单顺序迭代,根据您的 Java 编程经验,我想您应该非常清楚该怎么做:
清单 10. 对一个对象使用 for 循环和对所有对象使用 for 循环
// This is Scala object Application { def main(args : Array[String]) { for (i <- 1 to 10) // the left-arrow means "assignment" in Scala System.out.println("Counting " + i) } } |
此代码所做的正如您期望的那样,循环 10 次,并且每次都输出一些值。需要小心的是:表达式 “1 to 10” 并不意味着 Scala 内置了整数感知(awareness of integer)以及从 1 到 10 的计数方式。从技术上说,这里存在一些更微妙的地方:编译器使用 Int
类型上定义的方法 to
生成一个 Range
对象(Scala 中的任何东西都是对象,还记得吗?),该对象包含要迭代的元素。如果用 Scala 编译器可以看见的方式重新编写上述代码,那么该代码看起来很可能如下所示:
// This is Scala object Application { def main(args : Array[String]) { for (i <- 1.to(10)) // the left-arrow means "assignment" in Scala System.out.println("Counting " + i) } } |
实际上,Scala 的 for
并不了解那些成员,并且并不比其他任何对象类型做得更好。它所了解的是 scala.Iterable
,scala.Iterable 定义了在集合上进行迭代的基本行为。提供 Iterable
功能(从技术上说,它是 Scala 中的一个特征,但现在将它视为一个接口)的任何东西都可以用作 for
表达式的核心。List
、Array
,甚至是您自己的自定义类型,都可以在 for
中使用。
|
正如上面已经证明的那样,for
循环可以做许多事情,并不只是遍历可迭代的项列表。事实上,可以使用一个 for
循环在操作过程中过滤许多项,并在每个阶段都产生一个新列表:
// This is Scala object Application { def main(args : Array[String]) { for (i <- 1 to 10; i % 2 == 0) System.out.println("Counting " + i) } } |
注意到清单 12 中 for
表达式的第二个子句了吗?它是一个过滤器,实际上,只有那些传递给过滤器(即计算 true)的元素 “向前传给” 了循环主体。在这里,只输出了 1 到 10 的偶数数字。
并不要求 for
表达式的各个阶段都成为过滤器。您甚至可以将一些完全平淡无奇的东西(从循环本身的观点来看)放入管道中。例如以下代码显示了在下一个阶段进行计算之前的 i 的当前值:
// This is Scala object App { def log(item : _) : Boolean = { System.out.println("Evaluating " + item) true } def main(args : Array[String]) = { for (val i <- 1 to 10; log(i); (i % 2) == 0) System.out.println("Counting " + i) } } |
在运行的时候,范围 1 到 10 中的每个项都将发送给 log
,它将通过显式计算每个项是否为 true 来 “批准” 每个项。然后,for
的第三个子句将对这些项进行筛选,过滤出那些满足是偶数的条件的元素。因此,只将偶数传递给了循环主体本身。
在 Scala 中,可以将 Java 代码中复杂的一长串语句缩短为一个简单的表达式。例如,以下是遍历目录查找所有 .scala 文件并显示每个文件名称的方法:
// This is Scala object App { def main(args : Array[String]) = { val filesHere = (new java.io.File(".")).listFiles for ( file <- filesHere; if file.isFile; if file.getName.endsWith(".scala") ) System.out.println("Found " + file) } } |
这种 for 过滤很常见(并且在此上下文中,分号很让人讨厌),使用这种过滤是为了帮助您做出忽略分号的决定。此外,Scala 允许将上述示例中的圆括号之间的语句直接作为代码块对待:
// This is Scala object App { def main(args : Array[String]) = { val filesHere = (new java.io.File(".")).listFiles for { file <- filesHere if file.isFile if file.getName.endsWith(".scala") } System.out.println("Found " + file) } } |
作为 Java 开发人员,您可能发现最初的圆括号加分号的语法更直观一些,没有分号的曲线括号语法很难读懂。幸运的是,这两种句法产生的代码是等效的。
在 for
表达式的子句中可以分配一个以上的项,如清单 16 中所示。
// This is Scala object App { def main(args : Array[String]) = { // Note the array-initialization syntax; the type (Array[String]) // is inferred from the initialized elements val names = Array("Ted Neward", "Neal Ford", "Scott Davis", "Venkat Subramaniam", "David Geary") for { name <- names firstName = name.substring(0, name.indexOf(' ')) } System.out.println("Found " + firstName) } } |
这被称为 “中途赋值(midstream assignment)”,其工作原理如下:定义了一个新值 firstName
,该值用于保存每次执行循环后的 substring
调用的值,以后可以在循环主体中使用此值。
这还引出了嵌套 迭代的概念,所有迭代都位于同一表达式中:
在此示例中,grep
内部的 for
使用了两个嵌套迭代,一个在指定目录(其中每个文件都与 file
连接在一起)中找到的所有文件上进行迭代,另一个迭代在目前正被迭代的文件(与 line
本地变量连接在一起)中发现的所有行上进行迭代。
使用 Scala 的 for
结构可以做更多的事,但目前为止提供的示例已足以表达我的观点:Scala 的 for
实际上是一条管道,它在将元素传递给循环主体之前处理元素组成的集合,每次一个。此管道其中的一部分负责将更多的元素添加到管道中(生成器),一部分负责编辑管道中的元素(过滤器),还有一些负责处理中间的操作(比如记录)。无论如何,Scala 会带给您与 Java 5 中引入的 “增强的 for
循环” 不同的体验。
今天要了解的最后一个 Scala 控制结构是 match
,它提供了许多 Scala 模式匹配功能。幸运的是,模式匹配会声明对某个值进行计算的代码块。首先,将执行代码块中最接近的匹配结果。因此,在 Scala 中可以包含以下代码:
刚开始您可能将 Scala 模式匹配设想为支持 String
的 “开关’,带有通常用作通配符的下划线字符,而这正是典型开关中的默认情况。但是,这样想会极大地低估该语言。模式匹配是许多(但不是大多数)函数语言中可以找到的另一个特性,它提供了一些有用的功能。
对于初学者(尽管这没什么好奇怪的),可能认为 match
表达式自身会产生一个值,该值可能出现在赋值语句的右边,正如 if
和 try
语句所做的那样。这一点本身也很有用,但匹配的真正威力体现在基于各种类型进行匹配时,而不是如上所述匹配单个类型的值,或者更多的时候,它是两种匹配的组合。
因此,假设您有一个声明返回 Object
的函数或方法 —— 在这里,Java 的 java.lang.reflect.Method.invoke()
方法的结果可能是一个好例子。通常,在使用 Java 语言计算结果时,首先应该确定其类型;但在 Scala 中,可以使用模式匹配简化该操作:
因为 match 的很容易简单明了地描述如何针对各种值和类型进行匹配的能力,模式匹配常用于解析器和解释器中,在那里,解析流中的当前标记是与一系列可能的匹配子句匹配的。然后,将针对另一系列子句应用下一个标记,依此类推(注意,这也是使用函数语言编写许多语言解析器、编译器和其他与代码有关的工具的部分原因,这些函数语言中包括 Haskell 或 ML)。
关于模式匹配,还有许多可说的东西,但这些会将我们直接引导至 Scala 的另一个特性 case 类,我想将它留到下次再介绍。