Scala的下一步

第七步:带类型的参数化数组

Scala里可以使用new实例化对象或类实例。当你在Scala里实例化对象,可以使用值和类型把它参数化:parameterize。参数化的意思是在你创建实例的时候“设置”它。通过把加在括号里的对象传递给实例的构造器的方式来用值参数化实例。例如,下面的Scala代码实例化一个新的java.math.BigInteger并使用值"12345"参数化:

val big = new java.math.BigInteger("12345")

通过在方括号里设定一个或更多类型来参数化实例。通过在方括号里设定一个或更多类型来参数化实例。代码3.1展示了一个例子。在这个例子中,greetStrings是类型Array[String](字串数组)的值,并被第一行代码里的值3参数化,使它的初始长度为3。如果把代码3.1里的代码作为脚本执行,你会看到另一个Hello, world!的祝词。请注意当你同时用类型和值去参数化实例的时候,类型首先在方括号中出现,然后跟着值在圆括号中。

val greetStrings = new Array[String](3) 
greetStrings(0) = "Hello"
greetStrings(1) = ", "
greetStrings(2) = "world!\n"

for (i <- 0 to 2)
print(greetStrings(i))

正如前面提到的,Scala里的数组是通过把索引放在圆括号里面访问的,而不是像Java那样放在方括号里。所以数组的第零个元素是greetStrings(0),不是greetStrings[0]。

这三行代码演示了搞明白Scala如何看待val的意义的重要概念。当你用val定义一个变量,那么这个变量就不能重新赋值,但它指向的对象却仍可以暗自改变。所以在本例中,你不能把greetStrings重新赋值成不同的数组;greetStrings将永远指向那个它被初始化时候指向的同一个Array[String]实例。但是你能一遍遍修改那个Array[String]的元素,因此数组本身是可变的。

代码3.1的最后两行包含一个for表达式用来依次输出每个greetStrings数组元素。

for (i <- 0 to 2) 
print(greetStrings(i))

这个for表达式的第一行代码演示了Scala的另一个通用规则:如果方法仅带一个参数,你可以不带点或括号的调用它。本例中的to实际上是带一个Int参数的方法。代码0 to 2被转换成方法调用(0).to(2)。请注意这个语法仅在你显示指定方法调用的接受者时才起作用。不可以写 pringln 10,但是可以写成“Console println 10”。

从技术上讲,Scala没有操作符重载,因为它根本没有传统意义上的操作符。取而代之的是,诸如+,-,*和/这样的字符可以用来做方法名。因此,当第一步里你在Scala解释器里输入1 + 2,你实际上正在Int对象1上调用一个名为+的方法,并把2当作参数传给它。如图3.1所示,你也可以使用传统的方法调用语法把1 + 2替代写成(1).+(2)。

              

 

这里演示的另一重要思想可以让你看到为什么数组在Scala里是用括号访问的。与Java比,Scala很少有特例。数组和Scala里其他的类一样只是类的实现。当你在一个或多个值或变量外使用括号时,Scala会把它转换成对名为apply的方法调用。于是greetStrings(i)转换成greetStrings.apply(i)。所以Scala里访问数组的元素也只不过是跟其它的一样的方法调用。这个原则不仅仅局限于数组:任何对某些在括号中的参数的对象的应用将都被转换为对apply方法的调用。当然前提是这个类型实际定义过apply方法。所以这不是一个特例,而是一个通则。

与之相似的是,当对带有括号并包括一到若干参数的变量赋值时,编译器将把它转化为对带有括号里参数和等号右边的对象的update方法的调用。例如,

greetStrings(0) = "Hello"

将被转化为

greetStrings.update(0, "Hello")

因此,下列Scala代码与你在代码3.1里的代码语义一致:

val greetStrings = new Array[String](3) 
greetStrings.update(0, "Hello")
greetStrings.update(1, ", ") 
greetStrings.update(2, "world!\n") 
for (i <- 0.to(2)) 
print(greetStrings.apply(i))

Scala在对待任何事上追求概念的简洁性,从数组到表达式,包括带有方法的对象。你不必记住太多特例,如Java里原始类型和相应的包装类间的,或者数组和正常的对象间的差别。而且这种统一并未损害重要的性能代价。Scala编译器使用Java数组,原始类型,及可存在于编译完成代码里的原生数学类型。尽管目前为止在这一步里你看到的例子编译运行良好,Scala提供了通常可以用在你真实代码里的更简洁的方法创造和初始化数组。它看起来就像展示在代码3.2中的样子。这行代码创建了长度为3的新数组,用传入的字串"zero","one"和"two"初始化。编译器推断数组的类型是Array[String] ,因为你把字串传给它。

val numNames = Array("zero", "one", "two")

你在代码3.2里实际做的就是调用了一个叫做apply的工厂方法,从而创造并返回了新的数组。apply方法带可变数量个参数被定义在Array的伴生对象:companion object上。。如果你是一个Java程序员,你可以认为这个就像在Array类上调用一个叫做apply的静态方法。更罗嗦的调用同样的apply方法的办法是:

val numNames2 = Array.apply("zero", "one", "two")

第八步:使用List

方法不应该有副作用是函数风格编程的一个很重要的理念。方法唯一的效果应该是计算并返回值。用这种方式工作的好处就是方法之间很少纠缠在一起,因此就更加可靠和可重用。另一个好处(静态类型语言里)是传入传出方法的所有东西都被类型检查器检查,因此逻辑错误会更有可能把自己表现为类型错误。把这个函数式编程的哲学应用到对象世界里意味着使对象不可变。

如你所见,Scala数组是一个所有对象都共享相同类型的可变序列。比方说Array[String]仅包含String。尽管实例化之后你无法改变Array的长度,它的元素值却是可变的。因此,Array是可变的对象。

说到共享相同类型的不可变对象序列,Scala的List类才是。和数组一样,List[String]包含的仅仅是String。Scala的List,scala.List,不同于Java的java.util.List,总是不可变的(而Java的List可变)。更通常的说法,Scala的List是设计给函数式风格的编程用的。创建一个List很简单。代码3.3做了展示:

val oneTwoThree = List(1, 2, 3)

代码3.3中的代码完成了一个新的叫做oneTwoThree的val,并已经用带有整数元素值1,2和3的新List[Int]初始化。因为List是不可变的,他们表现得有些像Java的String:当你在一个List上调用方法时,似乎这个名字指代的List看上去被改变了,而实际上它只是用新的值创建了一个List并返回。比方说,List有个叫“:::”的方法实现叠加功能。你可以这么用:

val oneTwo = List(1, 2) 
val threeFour = List(3, 4) 
val oneTwoThreeFour = oneTwo ::: threeFour 
println(oneTwo + " and " + threeFour + " were not mutated.") 
println("Thus, " + oneTwoThreeFour + " is a new List.")

如果你执行这个脚本,你会看到:

List(1, 2) and List(3, 4) were not mutated. 
Thus, List(1, 2, 3, 4) is a new List.

或许List最常用的操作符是发音为“cons”的‘::’。Cons把一个新元素组合到已有List的最前端,然后返回结果List。例如,若执行这个脚本:

val twoThree = list(2, 3) 
val oneTwoThree = 1 :: twoThree 
println(oneTwoThree)

你会看到:

List(1, 2, 3)

注意:表达式“1 :: twoThree”中,::是它右操作数,列表twoThree,的方法。你或许会疑惑::方法的关联性上有什么东西搞错了,不过这只是一个简单的需记住的规则:如果一个方法被用作操作符标注,如a * b,那么方法被左操作数调用,就像a.*(b)——除非方法名以冒号结尾。这种情况下,方法被右操作数调用。因此,1 :: twoThree里,::方法被twoThree调用,传入1,像这样:twoThree.::(1)。

由于定义空类的捷径是Nil,所以一种初始化新List的方法是把所有元素用cons操作符起来,Nil作为最后一个元素。比方说,下面的脚本将产生与之前那个同样的输出,“List(1, 2, 3)”

val oneTwoThree = 1 :: 2 :: 3 :: Nil 
println(oneTwoThree)

要在最后用到Nil的理由是::是定义在List类上的方法。如果你想只是写成1 :: 2 :: 3,由于3是Int类型,没有::方法,因此会导致编译失。

Scala的List包装了很多有用的方法,表格3.1罗列了其中的一些。列表的全部实力将在第十六章释放。

为什么列表不支持append?

类List没有提供append操作,因为随着列表变长append的耗时将呈线性增长,而使用::做前缀则仅花费常量时间。如果你想通过添加元素来构造列表,你的选择是把它们前缀进去,当你完成之后再调用reverse;或使用ListBuffer,一种提供append操作的可变列表,当你完成之后调用toList。ListBuffer将在22.2节中描述。

                                        表格3.1 类型List的一些方法和作用

方法名 方法作用
List() 或 Nil 空List
List("Cool", "tools", "rule) 创建带有三个值"Cool","tools"和"rule"的新List[String]
val thrill = "Will"::"fill"::"until"::Nil 创建带有三个值"Will","fill"和"until"的新List[String]
List("a", "b") ::: List("c", "d") 叠加两个列表(返回带"a","b","c"和"d"的新List[String])
thrill(2) 返回在thrill列表上索引为2(基于0)的元素(返回"until")
thrill.count(s => s.length == 4) 计算长度为4的String元素个数(返回2)
thrill.drop(2) 返回去掉前2个元素的thrill列表(返回List("until"))
thrill.dropRight(2) 返回去掉后2个元素的thrill列表(返回List("Will"))
thrill.exists(s => s == "until") 判断是否有值为"until"的字串元素在thrill里(返回true)
thrill.filter(s => s.length == 4) 依次返回所有长度为4的元素组成的列表(返回List("Will", "fill"))
thrill.forall(s => s.endsWith("1")) 辨别是否thrill列表里所有元素都以"l"结尾(返回true)
thrill.foreach(s => print(s)) 对thrill列表每个字串执行print语句("Willfilluntil")
thrill.foreach(print) 与前相同,不过更简洁(同上)
thrill.head 返回thrill列表的第一个元素(返回"Will")
thrill.init 返回thrill列表除最后一个以外其他元素组成的列表(返回List("Will", "fill"))
thrill.isEmpty 说明thrill列表是否为空(返回false)
thrill.last 返回thrill列表的最后一个元素(返回"until")
thrill.length 返回thrill列表的元素数量(返回3)
thrill.map(s => s + "y") 返回由thrill列表里每一个String元素都加了"y"构成的列表(返回List("Willy", "filly", "untily"))
thrill.mkString(", ") 用列表的元素创建字串(返回"will, fill, until")
thrill.remove(s => s.length == 4) 返回去除了thrill列表中长度为4的元素后依次排列的元素列表(返回List("until"))
thrill.reverse 返回含有thrill列表的逆序元素的列表(返回List("until", "fill", "Will"))
thrill.sort((s, t) => s.charAt(0).toLowerCase < t.charAt(0).toLowerCase) 返回包括thrill列表所有元素,并且第一个字符小写按照字母顺序排列的列表(返回List("fill", "until", "Will"))
thrill.tail 返回除掉第一个元素的thrill列表(返回List("fill", "until"))

 

第九步:使用Tuple

另一种有用的容器对象是元组:tuple。与列表一样,元组也是不可变的,但与列表不同,元组可以包含不同类型的元素。而列表应该是List[Int]或List[String]的样子,元组可以同时拥有Int和String。元组很有用,比方说,如果你需要在方法里返回多个对象。Java里你将经常创建一个JavaBean样子的类去装多个返回值,Scala里你可以简单地返回一个元组。而且这么做的确简单:实例化一个装有一些对象的新元组,只要把这些对象放在括号里,并用逗号分隔即可。一旦你已经实例化了一个元组,你可以用点号,下划线和一个基于1的元素索引访问它。代码3.4展示了一个例子:

val pair = (99, "Luftballons") 
println(pair._1) 
println(pair._2)

 代码3.4的第一行,你创建了元组,它的第一个元素是以99为值的Int,第二个是"luftballons"为值的String。Scala推断元组类型为Tuple2[Int, String],并把它赋给变量pair。第二行,你访问_1字段,从而输出第一个元素,99。第二行的这个“.”与你用来访问字段或调用方法的点没有区别。本例中你正用来访问名叫_1的字段。如果执行这个脚本,你能看到:

99 
Luftballons

元组的实际类型取决于它含有的元素数量和这些元素的类型。因此,(99, "Luftballons")的类型是Tuple2[Int, String]。('u', 'r', 'the', 1, 4, "me")是Tuple6[Char, Char, String, Int, Int, String]。

访问元组的元素:

你或许想知道为什么你不能像访问List里的元素那样访问元组的,就像pair(0)。那是因为List的apply方法始终返回同样的类型,但是元组里的或许类型不同。_1可以有一个结果类型,_2是另外一个,诸如此类。这些_N数字是基于1的,而不是基于0的,因为对于拥有静态类型元组的其他语言,如Haskell和ML,从1开始是传统的设定。

第十步:使用Set和Map

因为Scala致力于帮助你充分利用函数式和指令式风格两方面的好处,它的集合类型库于是就区分了集合类的可变和不可变。例如,数组始终是可变的,而列表始终不可变。当问题讨论到集和映射,Scala同样提供了可变和不可变的替代品,不过用了不同的办法。对于集和映射,Scala把可变性建模在类继承中。

例如,Scala的API包含了集的一个基本特质:trait,特质这个概念接近于Java的接口

)Scala于是提供了两个子特质,一个是可变的集,另一个是不可变的集。就如你在图3.2里会看到的,这三个特质都共享同样的简化名,Set。然而它们的全称不一样,因为每个都放在不同的包里。Scala的API里具体的Set类,如图3.2的HashSet类,扩展了要么是可变的,要么不可变的Set特质。(尽管Java里面称为“实现”了接口,在Scala里面称为“扩展”或“混入”了特质。)因此,如果你想要使用HashSet,你可以根据你的需要选择可变的或不可变的变体。创造集的缺省方法展示在代码3.5中:

var jetSet = Set("Boeing", "Airbus") 
jetSet += "Lear" 
println(jetSet.contains("Cessna"))

代码3.5的第一行代码里,定义了名为jetSet的新var,并使用了包含两个字串,"Boeing"和"Airbus"的不可变集完成了初始化。就像例子中展示的,Scala中创建集的方法与创建列表和数组的类似:通过调用Set伴生对象的名为apply的工厂方法。代码3.5中,对scala.collection.immutable.Set的伴生对象调用了apply方法,返回了一个缺省的,不可变Set的实例。Scala编译器推断jetSet的类型为不可变Set[String]。

                  

要向集加入新的变量,可以在集上调用+,传入新的元素。可变的和不可变的集都提供了+方法,但它们的行为不同。可变集将把元素加入自身,不可变集将创建并返回一个包含了添加元素的新集。代码3.5中,你使用的是不可变集,因此+调用将产生一个全新集。因此尽管可变集提供的实际上是+=方法,不可变集却不是。本例中,代码的第二行,“jetSet += "Lear"”,实质上是下面写法的简写:

jetSet = jetSet + "Lear"

因此在代码3.5的第二行,你用一个包含了"Boeing","Airbus"和"Lear"的新集重新赋值了jetSet这个var。最终,代码3.5的最后一行打印输出了集是否包含字串"Cessna"。(正如你所料到的,输出false。)

如果你需要不可变集,就需要使用一个引用:import,如代码3.6所示:

import scala.collection.mutable.Set 
val movieSet = Set("Hitch", "Poltergeist") 
movieSet += "Shrek" 
println(movieSet)

代码3.6的第一行里引用了可变Set。就像Java那样,引用语句允许你使用简单名,如Set,以替代更长的,全标识名。结果,当你在第三行写Set的时候,编译器就知道你是指scala.collection.mutable.Set。在那行里,你使用包含字串"Hitch"和"Poltergeist"的新可变集初始化了movieSet。下一行通过在集上调用+=方法向集添加了"Shrek"。正如前面提到的,+=是实际定义在可变集上的方法。如果你想的话,你可以替换掉movieSet += "Shrek"的写法,写成movieSet.+=("Shrek")。

尽管目前为止看到的通过可变和不可变的Set工厂方法制造的缺省的集实现很可能能够满足极大多数的情况,但偶尔你也或许想要个显式的集类。幸运的是,语法是相同的。只要引用你需要的类,并使用它伴生对象的工厂方法即可。例如,如果你需要一个不可变的HashSet,你可以这么做:

import scala.collection.immutable.HashSet 
val hashSet = HashSet("Tomatoes", "Chilies") 
println(hashSet + "Coriander")

Map是Scala里另一种有用的集合类。和集一样,Scala采用了类继承机制提供了可变的和不可变的两种版本的Map,你能在图3.3里看到,Map的类继承机制看上去和Set的很像。scala.collection包里面有一个基础Map特质和两个子特质Map:可变的Map在scala.collection.mutable里,不可变的在scala.collection.immutable里。

Map的实现,如显示在类继承图3.3里的HashMap,扩展了要么可变,要么不可变特质。你可以使用与那些用在数组,列表和集中的一样的工厂方法去创造和初始化映射。例如,代码3.7展示了可变映射的创造过程:

import scala.collection.mutable.Map 
val treasureMap = Map[Int, String]() 
treasureMap += (1 -> "Go to island.") 
treasureMap += (2 -> "Find big X on ground.") 
treasureMap += (3 -> "Dig.")
println(treasureMap(2))

 

 

                      

代码3.7的第一行里,你引用了可变形式的Map。然后就定义了一个叫做treasureMap的val并使用空的包含整数键和字串值的可变Map初始化它。映射为空是因为你没有向工厂方法传递任何值(“Map[Int, String]()”的括号里面是空的)

下面的三行里你使用->和+=方法把键/值对添加到Map里。像前面例子里演示的那样,Scala编译器把如1 -> "Go to island"这样的二元操作符表达式转换为(1).->("Go to island.")。因此,当你输入1 -> "Go to island.",你实际上是在值为1的Int上调用->方法,并传入值为"Go to island."的String。这个->方法可以调用Scala程序里的任何对象,并返回一个包含键和值的二元元组。然后你在把这个元组传递给treasureMap指向的Map的+=方法。最终,最后一行输出打印了treasureMap中的与键2有关的值。如果你执行这段代码,将会打印:

Find big X on ground.

如果你更喜欢不可变映射,就不用引用任何类了,因为不可变映射是缺省的代码3.8展示了这个例子:

val romanNumeral = Map( 1 -> "I", 2 -> "II", 3 -> "III", 4 -> "IV", 5 -> "V" ) println(romanNumeral(4))

由于没有引用,当你在代码3.8的第一行里提及Map时,你会得到缺省的映射:scala.collection.immutable.Map。传给工厂方法入五个键/值元组,返回包含这些传入的键/值对的不可变Map。如果你执行代码3.8中的代码,将会打印输出IV。

第十一步:学习识别函数式风格

第1章里提到过,Scala允许你用指令式风格编程,但是鼓励你采用一种更函数式的风格。

通向更函数式风格路上的第一步是识别这两种风格在代码上的差异。其中的一点蛛丝马迹就是,如果代码包含了任何var变量,那它大概就是指令式的风格。如果代码根本就没有var——就是说仅仅包含val——那它大概是函数式的风格。因此向函数式风格推进的一个方式,就是尝试不用任何var编程。

如果你来自于指令式的背景,如Java,C++,或者C#,你或许认为var是很正统的变量而val是一种特殊类型的变量。相反,如果你来自于函数式背景,如Haskell,OCamel,或Erlang,你或许认为val是一种正统的变量而var有亵渎神灵的血统。然而在Scala看来,val和var只不过是你工具箱里两种不同的工具。它们都很有用,没有一个天生是魔鬼。Scala鼓励你学习val,但也不会责怪你对给定的工作选择最有效的工具。尽管或许你同意这种平衡的哲学,你或许仍然发现第一次理解如何从你的代码中去掉var是很挑战的事情。

考虑下面这个改自于第2章的while循环例子,它使用了var并因此属于指令式风格:

def printArgs(args: Array[String]): Unit = {
     var i = 0 
     while (i < args.length) {
         println(args(i)) i += 1 
    } 
}

你可以通过去掉var的办法把这个代码变得更函数式风格,例如,像这样:

def printArgs(args: Array[String]): Unit = { 
for (arg <- args) println(arg) 
}

或这样:

def printArgs(args: Array[String]): Unit = { 
    args.foreach(println) 
}

这个例子演示了减少使用var的一个好处。重构后(更函数式)的代码比原来(更指令式)的代码更简洁,明白,也更少机会犯错。Scala鼓励函数式风格的原因,实际上也就是因为函数式风格可以帮助你写出更易读懂,更不容易犯错的代码。

当然,你可以走得更远。重构后的printArgs方法并不是纯函数式的,因为它有副作用——本例中,其副作用是打印到标准输出流。函数有副作用的马脚就是结果类型为Unit。如果某个函数不返回任何有用的值,就是说其结果类型为Unit,那么那个函数唯一能让世界有点儿变化的办法就是通过某种副作用。更函数式的方式应该是定义对需打印的arg进行格式化的方法,但是仅返回格式化之后的字串,如代码3.9所示:

def formatArgs(args: Array[String]) = args.mkString("\n")

现在才是真正函数式风格的了:满眼看不到副作用或者var。能在任何可枚举的集合类型(包括数组,列表,集和映射)上调用的mkString方法,返回由每个数组元素调用toString产生结果组成的字串,以传入字串间隔。因此如果args包含了三个元素,"zero","one"和"two",formatArgs将返回"zero\none\ntwo"。当然,这个函数并不像printArgs方法那样实际打印输出,但可以简单地把它的结果传递给println来实现。

每个有用的程序都可能有某种形式的副作用,因为否则就不可能对外部世界提供什么值。偏好于无副作用的方法可以鼓励你设计副作用代码最少化了的程序。这种方式的好处之一是可以有助于使你的程序更容易测试。举例来说,要测试本节之前给出三段printArgs方法的任一个,你将需要重定义println,捕获传递给它的输出,并确信这是你希望的。相反,你可以通过检查结果来测试formatArgs:

val res = formatArgs(Array("zero", "one", "two")) 
assert(res == "zero\none\ntwo")

Scala的assert方法检查传入的Boolean并且如果是假,抛出AssertionError。如果传入的Boolean是真,assert只是静静地返回。

Scala程序员的平衡感

崇尚val,不可变对象和没有副作用的方法。

首先想到它们。只有在特定需要和判断之后才选择var,可变对象和有副作用的方法。

第十二步:从文件里读取信息行

本节中,你将建立一个从文件中读行记录,并把行中字符个数前置到每一行,打印输出的脚本。第一版展示在代码3.10中:

import scala.io.Source 
if (args.length > 0) {
     for (line <- Source.fromFile(args(0)).getLines) 
    print(line.length + " " + line) 
} 
else Console.err.println("Please enter filename")

此脚本开始于从包scala.io引用名为Source的类。然后检查是否命令行里定义了至少一个参数。若是,则第一个参数被解释为要打开和处理的文件名。表达式Source.fromFile(args(0)),尝试打开指定的文件并返回一个Source对象,你在其上调用getLines。函数返回Iterator[String],在每个枚举里提供一行包括行结束符的信息。for表达式枚举这些行并打印每行的长度,空格和这行记录。如果命令行里没有提供参数,最后的else子句将在标准错误流中打印一条信息。如果你把这些代码放在文件contchars1.scala,并运行它调用自己:

$ scala countchars1.scala countchars1.scala23 import scala.io.Source 1 23 if (args.length > 0) { 1 50 for (line <- Source.fromFile(args(0)).getLines) 36 print(line.length + " " + line) 2 } 5 else 47 Console.err.println("Please enter filename")