对于学习 Scala 的 Java™ 开发人员来说,对象是一个比较自然、简单的入口点。在 本系列 前几期文章中,我介绍了 Scala 中一些面向对象的编程方法,这些方法实际上与 Java 编程的区别不是很大。我还向您展示了 Scala 如何重新应用传统的面向对象概念,找到其缺点,并根据 21 世纪的新需求重新加以改造。Scala 一直隐藏的一些重要内容将要现身:Scala 也是一种函数语言(这里的函数性是与其他 dys 函数语言相对而言的)。
Scala 的面向函数性非常值得探讨,这不仅是因为已经研究完了对象内容。Scala 中的函数编程将提供一些新的设计结构和理念以及一些内置构造,它们使某些场景(例如并发性)的编程变得非常简单。
本月,您将首次进入 Scala 的函数编程领域,查看大多数函数语言中常见的四种类型:列表(list)、元组(tuple)、集合(set)和 Option
类型。您还将了解 Scala 的数组,后者对其他函数语言来说十分新鲜。 这些类型都提出了编写代码的新方式。当结合传统面向对象特性时,可以生成十分简洁的结果。
使用 Option(s)
在什么情况下,“无” 并不代表 “什么也没有”?当它为 0 的时候,与 null 有什么关系。
对于我们大多数人都非常熟悉的概念,要在软件中表示为 “无” 是一件十分困难的事。例如,看看 C++ 社区中围绕 NULL
和 0 进行的激烈讨论,或是 SQL 社区围绕 NULL
列值展开的争论,便可知晓一二。 NULL
或 null 对于大多数程序员来说都表示 “无”,但是这在 Java 语言中引出了一些特殊问题。
考虑一个简单操作,该操作可以从一些位于内存或磁盘的数据库查找程序员的薪资:API 允许调用者传入一个包含程序员名字的 String
,这会返回什么呢?从建模角度来看,它应该返回一个 Int
,表示程序员的年薪;但是这里有一个问题,如果程序员不在数据库中(可能根本没有雇用她,或者已经被解雇,要不就是输错了名字……),那么应该返回什么。如果返回类型是 Int
,则不能返回 null,这个 “标志” 通常表示没有在数据库中找到该用户(您可能认为应该抛出一个异常,但是大多数时候数据库丢失值并不能视为异常,因此不应该在这里抛出异常)。
在 Java 代码中,我们最终将方法标记为返回 java.lang.Integer
,这迫使调用者知道方法可以返回 null。自然,我们可以依靠程序员来全面归档这个场景,还可以依赖程序员读取精心准备的文档。这类似于:我们可以要求经理倾听我们反对他们要求的不可能完成的项目期限,然后经理再进一步把我们的反对传达给上司和用户。
Scala 提供了一种普通的函数方法,打破了这一僵局。在某些方面,Option
类型或Option[T]
,并不重视描述。它是一个具有两个子类 Some[T]
和 None
的泛型类,用来表示 “无值” 的可能性,而不需要语言类型系统大费周折地支持这个概念。实际上,使用Option[T]
类型可以使问题更加清晰(下一节将用到)。
在使用 Option[T]
时,关键的一点是认识到它实质上是一个大小为 “1” 的强类型集合,使用一个不同的值 None 表示 “nothing” 值的可能性。因此,在这里方法没有返回 null 表示没有找到数据,而是进行声明以返回 Option[T]
,其中 T
是返回的原始类型。那么,对于没有查找到数据的场景,只需返回 None,如下所示:
清单 1. 准备好踢足球了吗?
@Test def simpleOptionTest =
{
val footballTeamsAFCEast =
Map("New England" -> "Patriots",
"New York" -> "Jets",
"Buffalo" -> "Bills",
"Miami" -> "Dolphins",
"Los Angeles" -> null)
assertEquals(footballTeamsAFCEast.get("Miami"), Some("Dolphins"))
assertEquals(footballTeamsAFCEast.get("Miami").get(), "Dolphins")
assertEquals(footballTeamsAFCEast.get("Los Angeles"), Some(null))
assertEquals(footballTeamsAFCEast.get("Sacramento"), None)
}
注意,Scala Map
中 get
的返回值实际上并不对应于传递的键。相反,它是一个 Option[T]
实例,可以是与某个值有关的 Some()
,也可以是 None
,因此可以很清晰地表示没有在 map 中找到键。如果它可以表示 map 上存在某个键,但是有对应的 null 值,这一点特别重要了。比如清单 1 中 Los Angeles 键。
通常,当处理 Option[T]
时,程序员将使用模式匹配,这是一个非常函数化的概念,它允许有效地 “启用” 类型和/或值,更不用说在定义中将值绑定到变量、在 Some()
和 None
之间切换,以及提取 Some
的值(而不需要调用麻烦的 get()
方法)。清单 2 展示了 Scala 的模式匹配:
清单 2. 巧妙的模式匹配
@Test def optionWithPM =
{
val footballTeamsAFCEast =
Map("New England" -> "Patriots",
"New York" -> "Jets",
"Buffalo" -> "Bills",
"Miami" -> "Dolphins")
def show(value : Option[String]) =
{
value match
{
case Some(x) => x
case None => "No team found"
}
}
assertEquals(show(footballTeamsAFCEast.get("Miami")), "Dolphins")
}
元组和集合
在 C++ 中,我们将之称为结构体。在 Java 编程中,我们称之为数据传输对象或参数对象。在 Scala 中,我们称为元组。实质上,它们是一些将其他数据类型收集到单个实例的类,并且不使用封装或抽象 — 实际上,不 使用任何抽象常常更有用。
在 Scala 创建一个元组类型非常的简单,这只是主体的一部分:如果首先将元素公开给外部,那么在类型内部创建描述这些元素的名称就毫无价值。考虑清单 3:
清单 3. tuples.scala
// JUnit test suite
//
class TupleTest
{
import org.junit._, Assert._
import java.util.Date
@Test def simpleTuples() =
{
val tedsStartingDateWithScala = Date.parse("3/7/2006")
val tuple = ("Ted", "Scala", tedsStartingDateWithScala)
assertEquals(tuple._1, "Ted")
assertEquals(tuple._2, "Scala")
assertEquals(tuple._3, tedsStartingDateWithScala)
}
}
创建元组非常简单,将值放入一组圆括号内,就好象调用一个方法调用一样。提取这些值只需要调用 “_n
” 方法,其中 n 表示相关的元组元素的位置参数:_1
表示第一位,_2
表示第二位,依此类推。传统的 Java java.util.Map
实质上是一个分两部分的元组集合。
元组可以轻松地实现使用单个实体移动多个值,这意味着元组可以提供在 Java 编程中非常重量级的操作:多个返回值。例如,某个方法可以计算 String
中字符的数量,并返回该 String
中出现次数最多的字符,但是如果程序员希望同时 返回最常出现的字符和 它出现的次数,那么程序设计就有点复杂了:或是创建一个包含字符及其出现次数的显式类,或将值作为字段保存到对象中并在需要时返回字段值。无论使用哪种方法,与使用 Scala 相比,都需要编写大量代码;通过简单地返回包含字符及其出现次数的元组,Scala 不仅可以轻松地使用 “_1”、“_2” 等访问元组的各个值,还可以轻松地返回多个返回值。
如下节所示,Scala 频繁地将 Option
和元组保存到集合(例如 Array[T]
或列表)中,从而通过一个比较简单的结构提供了极大的灵活性和威力。
数组带您走出阴霾
让我们重新审视一个老朋友 — 数组 — 在 Scala 中是 Array[T]
。和 Java 代码中的数组一样,Scala 的 Array[T]
是一组有序的元素序列,使用表示数组位置的数值进行索引,并且该值不可以超过数组的总大小,如清单 4 所示:
清单 4. array.scala
object ArrayExample1
{
def main(args : Array[String]) : Unit =
{
for (i <- 0 to args.length-1)
{
System.out.println(args(i))
}
}
}
尽管等同于 Java 代码中的数组(毕竟后者是最终的编译结果),Scala 中的数组使用了截然不同的定义。对于新手,Scala 中的数组实际上就是泛型类,没有增加 “内置” 状态(至少,不会比 Scala 库附带的其他类多)。例如,在 Scala 中,数组一般定义为 Array[T]
的实例,这个类定义了一些额外的有趣方法,包括常见的 “length” 方法,它将返回数组的长度。因此,在 Scala 中,可以按照传统意义使用 Array
,例如使用 Int
在 0 到 args.length
间进行迭代,并获取数组的第 i 个元素(使用圆括号而不是方括号来指定返回哪个元素,这是另一种名称比较有趣的方法)。
- 1
扩展数组
事实证明 Array
拥有大量方法,这些方法继承自一个非常庞大的 parent 层次结构:Array
扩展 Array0
,后者扩展 ArrayLike[A]
,ArrayLike[A]
扩展Mutable[A]
,Mutable[A]
又扩展 RandomAccessSeq[A]
,RandomAccessSeq[A]
扩展了 Seq[A]
,等等。实际上,这种层次结构意味着 Array
可以执行很多操作,因此与 Java 编程相比,在 Scala 中可以更轻松地使用数组。
例如,如清单 4 所示,使用 foreach
方法遍历数组更加简单并且更贴近函数的方式,这些都继承自 Iterable
特性:
清单 5. ArrayExample2
object
{
def main(args : Array[String]) : Unit =
{
args.foreach( (arg) => System.out.println(arg) )
}
}
看上去您没有节省多少工作,但是,将一个函数(匿名或其他)传入到另一个类中以便获得在特定语义下(在本例中指遍历数组)执行的能力,是函数编程的常见主题。以这种方式使用更高阶函数并不局限于迭代;事实上,还得经常对数组内容执行一些过滤 操作去掉无用的内容,然后再处理结果。例如,在 Scala 中,可以轻松地使用 filter
方法进行过滤,然后获取结果列表并使用 map
和另一个函数(类型为(T) => U
,其中 T 和 U 都是泛型类型),或 foreach
来处理每个元素。我在清单 6 中采取了后一种方法(注意 filter
使用了一个 (T) : Boolean
方法,意味着使用数组持有的任意类型的参数,并返回一个 Boolean
)。
清单 6. 查找所有 Scala 程序员
class ArrayTest
{
import org.junit._, Assert._
@Test def testFilter =
{
val programmers = Array(
new Person("Ted", "Neward", 37, 50000,
Array("C++", "Java", "Scala", "Groovy", "C#", "F#", "Ruby")),
new Person("Amanda", "Laucher", 27, 45000,
Array("C#", "F#", "Java", "Scala")),
new Person("Luke", "Hoban", 32, 45000,
Array("C#", "Visual Basic", "F#")),
new Person("Scott", "Davis", 40, 50000,
Array("Java", "Groovy"))
)
// Find all the Scala programmers ...
val scalaProgs =
programmers.filter((p) => p.skills.contains("Scala") )
// Should only be 2
assertEquals(2, scalaProgs.length)
// ... now perform an operation on each programmer in the resulting
// array of Scala programmers (give them a raise, of course!)
//
scalaProgs.foreach((p) => p.salary += 5000)
// Should each be increased by 5000 ...
assertEquals(programmers(0).salary, 50000 + 5000)
assertEquals(programmers(1).salary, 45000 + 5000)
// ... except for our programmers who don't know Scala
assertEquals(programmers(2).salary, 45000)
assertEquals(programmers(3).salary, 50000)
}
}
创建一个新的 Array
时将用到 map
函数,保持原始的数组内容不变,实际上大多数函数性程序员都喜欢这种方式:
清单 7. Filter 和 map
@Test def testFilterAndMap =
{
val programmers = Array(
new Person("Ted", "Neward", 37, 50000,
Array("C++", "Java", "Scala", "C#", "F#", "Ruby")),
new Person("Amanda", "Laucher", 27, 45000,
Array("C#", "F#", "Java", "Scala")),
new Person("Luke", "Hoban", 32, 45000,
Array("C#", "Visual Basic", "F#"))
new Person("Scott", "Davis", 40, 50000,
Array("Java", "Groovy"))
)
// Find all the Scala programmers ...
val scalaProgs =
programmers.filter((p) => p.skills.contains("Scala") )
// Should only be 2
assertEquals(2, scalaProgs.length)
// ... now perform an operation on each programmer in the resulting
// array of Scala programmers (give them a raise, of course!)
//
def raiseTheScalaProgrammer(p : Person) =
{
new Person(p.firstName, p.lastName, p.age,
p.salary + 5000, p.skills)
}
val raisedScalaProgs =
scalaProgs.map(raiseTheScalaProgrammer)
assertEquals(2, raisedScalaProgs.length)
assertEquals(50000 + 5000, raisedScalaProgs(0).salary)
assertEquals(45000 + 5000, raisedScalaProgs(1).salary)
}
注意,在清单 7 中,Person
的 salary 成员可以标记为 “val
”,表示不可修改,而不是像上文一样为了修改不同程序员的薪资而标记为 “var
”。
Scala 的 Array
提供了很多方法,在这里无法一一列出并演示。总的来说,在使用数组时,应该充分地利用 Array
提供的方法,而不是使用传统的 for ...
模式遍历数组并查找或执行需要的操作。最简单的实现方法通常是编写一个函数(如果有必要的话可以使用嵌套,如清单 7 中的 testFilterAndMap
示例所示),这个函数可以执行所需的操作,然后根据期望的结果将该函数传递给 Array
中的map
、filter
、foreach
或其他方法之一。
函数性列表
函数编程多年来的一个核心特性就是列表,它和数组在对象领域中享有相同级别的 “内置” 性。列表对于构建函数性软件非常关键,因此,您(作为一名刚起步的 Scala 程序员)必须能够理解列表及其工作原理。即使列表从未形成新的设计,但是 Scala 代码在其库中广泛使用了列表。因此学习列表是非常必要的。
在 Scala 中,列表类似于数组,因为它的核心定义是 Scala 库中的标准类 List[T]
。并且,和 Array[T]
相同,List[T]
继承了很多基类和特性,首先使用 Seq[T]
作为直接上层基类。
基本上,列表是一些可以通过列表头或列表尾提取的元素的集合。列表来自于 Lisp,后者是一种主要围绕 “LISt 处理” 的语言,它通过 car
操作获得列表的头部,通过 cdr
操作获得列表尾部(名称渊源与历史有关;第一个可以解释它的人有奖励)。
从很多方面来讲,使用列表要比使用数组简单,原因有二,首先函数语言过去一直为列表处理提供了良好的支持(而 Scala 继承了这些支持),其次可以很好地构成和分解列表。例如,函数通常从列表中挑选内容。为此,它将选取列表的第一个元素 — 列表头部 — 来对该元素执行处理,然后再递归式地将列表的其余部分传递给自身。这样可以极大减少处理代码内部具有相同共享状态的可能性,并且,假如每个步骤只需处理一个元素,极有可能使代码分布到多个线程(如果处理是比较好的)。
构成和分解列表非常简单,如清单 8 所示:
清单 8. 使用列表
class ListTest
{
import org.junit._, Assert._
@Test def simpleList =
{
val myFirstList = List("Ted", "Amanda", "Luke")
assertEquals(myFirstList.isEmpty, false)
assertEquals(myFirstList.head, "Ted")
assertEquals(myFirstList.tail, List("Amanda", "Luke")
assertEquals(myFirstList.last, "Luke")
}
}
注意,构建列表与构建数组十分相似;都类似于构建一个普通对象,不同之处是这里不需要 “new”(这是 “case 类” 的功能,我们将在未来的文章中介绍到)。请进一步注意 tail
方法调用的结果 — 结果并不是列表的最后一个元素(通过 last
提供),而是除第一个元素以外的其余列表元素。
当然,列表的强大力量部分来自于递归处理列表元素的能力,这表示可以从列表提取头部,直到列表为空,然后累积结果:
清单 9. 递归处理
@Test def recurseList =
{
val myVIPList = List("Ted", "Amanda", "Luke", "Don", "Martin")
def count(VIPs : List[String]) : Int =
{
if (VIPs.isEmpty)
0
else
count(VIPs.tail) + 1
}
assertEquals(count(myVIPList), myVIPList.length)
}
注意,如果不考虑返回类型 count
,Scala 编译器或解释器将会出现点麻烦 — 因为这是一个尾递归(tail-recursive)调用,旨在减少在大量递归操作中创建的栈帧的数量,因此需要指定它的返回类型。即使是这样,也可以轻松地使用 List
的 “length” 成员获取列表项的数量,但关键是如何解释列表处理强大的功能。清单 9 中的整个方法完全是线程安全的,因为列表处理中使用的整个中间状态保存在参数的堆栈上。因此,根据定义,它不能被多个线程访问。函数性方法的一个优点就是它实际上与程序功能截然不同,并且仍然创建共享的状态。
列表 API
列表具有另外一些有趣的特性,例如构建列表的替代方法,使用 ::
方法(是的,这是一种方法。只不过名称比较有趣)。因此,不必使用 “List
” 构造函数语法构建列表,而是将它们 “拼接” 在一起(在调用 ::
方法时),如清单 10 所示:
清单 10. 是 :: == C++ 吗?
@Test def recurseConsedList =
{
val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
def count(VIPs : List[String]) : Int =
{
if (VIPs.isEmpty)
0
else
count(VIPs.tail) + 1
}
assertEquals(count(myVIPList), myVIPList.length)
}
在使用 ::
方法时要小心 — 它引入了一些很有趣的规则。它的语法在函数语言中非常常见,因此 Scala 的创建者选择支持这种语法,但是要正确、普遍地使用这种语法,必须使用一种比较古怪的规则:任何以冒号结束的 “名称古怪的方法” 都是右关联(right-associative)的,这表示整个表达式从它的最右边的 Nil
开始,它正好是一个 List
。因此,可以将 ::
认定为一个全局的 ::
方法,与 String
的一个成员方法(本例中使用)相对;这又表示您可以对所有内容构建列表。在使用 ::
时,最右边的元素必须是一个列表,否则将得到一个错误消息。
在 Scala 中,列表的一种最强大的用法是与模式匹配结合。由于列表不仅可以匹配类型和值,它还可以同时绑定变量。例如,我可以简化清单 10 的列表代码,方法是使用模式匹配区别一个至少具有一个元素的列表和一个空列表:
清单 11. 结合使用模式匹配和列表
@Test def recurseWithPM =
{
val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
def count(VIPs : List[String]) : Int =
{
VIPs match
{
case h :: t => count(t) + 1
case Nil => 0
}
}
assertEquals(count(myVIPList), myVIPList.length)
}
在第一个 case
表达式中,将提取列表头部并绑定到变量 h,而其余部分(尾部)则绑定到 t;在本例中,没有对 h 执行任何操作(实际上,更好的方法是指明这个头部永远不会被使用,方法是使用一个通配符 _ 代替 h,这表明它是永远不会使用到的变量的占位符)。但是 t 被递归地传递给 count
,和前面的示例一样。还要注意,Scala 中的每一个表达式将隐式返回一个值;在本例中,模式匹配表达式的结果是递归调用 count
,当达到列表结尾时,结果为
+ 10
。
考虑到相同的代码量,使用模式匹配的价值体现在哪里?实际上,对于比较简单的代码,模式匹配的价值不很明显。但是对于稍微复杂的代码,例如扩展示例以匹配特定值,那么模式匹配非常有帮助。
清单 12. 模式匹配
@Test def recurseWithPMAndSayHi =
{
val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
var foundAmanda = false
def count(VIPs : List[String]) : Int =
{
VIPs match
{
case "Amanda" :: t =>
System.out.println("Hey, Amanda!"); foundAmanda = true; count(t) + 1
case h :: t =>
count(t) + 1
case Nil =>
0
}
}
assertEquals(count(myVIPList), myVIPList.length)
assertTrue(foundAmanda)
}
示例很快会变得非常复杂,特别是正则表达式或 XML 节点,开始大量使用模式匹配方法。模式匹配的使用同样不局限于列表;我们没有理由不把它扩展到前面的数组示例中。事实上,以下是前面的 recurseWithPMAndSayHi
测试的数组示例:
清单 13. 将模式匹配扩展到数组
@Test def recurseWithPMAndSayHi =
{
val myVIPList = Array("Ted", "Amanda", "Luke", "Don", "Martin")
var foundAmanda = false
myVIPList.foreach((s) =>
s match
{
case "Amanda" =>
System.out.println("Hey, Amanda!")
foundAmanda = true
case _ =>
; // Do nothing
}
)
assertTrue(foundAmanda)
}
如果希望进行实践,那么尝试构建清单 13 的递归版本,但这不用在 recurseWithPMAndSayHi
范围内声明一个可修改的 var
。提示:需要使用多个模式匹配代码块(本文的 代码下载 中包含了一个解决方案 — 但是建议您在查看之前首先自己进行尝试)。
结束语
Scala 是丰富的集合的集合(双关语),这源于它的函数历史和特性集;元组提供了一种简单的方法,可以很容易地收集松散绑定的值集合;Option[T]
可以使用简单的方式表示和 “no” 值相对的 “some” 值;数组可以通过增强的特性访问传统的 Java 式的数组语义;而列表是函数语言的主要集合,等等。
然而,需要特别注意其中一些特性,特别是元组:学会使用元组很容易,并且会因为为了直接使用元组而忘记传统的基本对象建模。如果某个特殊元组 — 例如,名称、年龄、薪资和已知的编程语言列表 — 经常出现在代码库中,那么将它建模为正常的类类型和对象。
Scala 的优点是它兼具函数性和 面向对象特性,因此,您可以在享受 Scala 的函数类型的同时,继续像以前一样关注类设计。