Scala编程--函数式对象
本章的重点在于定义函数式对象,也就是说,没有任何可变状态的对象的类。作为运行的例子,我们将创造若干把分数作为不可变对象建模的类的变体。在这过程中,我们会展示给你Scala面向对象编程的更多方面:类参数和构造函数,方法和操作符,私有成员,子类方法重载,先决条件检查,同类方法重载和自指向。
6.1 类Rational的式样书
一个,或许不怎么重要的,发现是数学上,分数不具有可变的状态。一个分数加到另外一个分数上,产生的结果是一个新的分数。而原来的数不会被“改变”。我们将在本章设计的不可变的Rational类将秉承这一属性。每个分数将都被表示成一个Rational对象。当两个Rational对象相加时,一个新的带着累加结果的Rational对象将被创建出来。
本章还将捎带提一些Scala让你写出感觉像原生语言支持的库的方法。例如,在本章结尾你将能用Rational类这样做:
1 scala> val oneHalf = new Rational(1, 2) 2 oneHalf: Rational = 1/2 3 scala> val twoThirds = new Rational(2, 3) 4 twoThirds: Rational = 2/3 5 scala> (oneHalf / 7) + (1 twoThirds) 6 res0: Rational = 17/42
6.2 创建Rational
开始设计Rational类的着手点是考虑客户程序员将如何创建一个新的Rational对象。假设我们已决定让Rational对象是不可变的,我们将需要那个客户在创建实例时提供所有需要的数据(本例中,是分子和分母)。因此,我们应该这么开始设计:
1 class Rational(n: Int, d: Int)
这行代码里首先应当注意到的是如果类没有主体,就不需要指定一对空的大括号(当然你如果想的话也可以)。在类名,Rational,之后括号里的n和d,被称为类参数:class parameter。Scala编译器会收集这两个类参数并创造一个带同样的两个参数的主构造器:primary constructor。
注意 这个最初的Rational例子凸显了Java和Scala之间的不同。Java类具有可以带参数的构造器,而Scala类可以直接带参数。Scala的写法更简洁——类参数可以直接在类的主体中使用;没必要定义字段然后写赋值函数把构造器的参数复制到字段里。这可以潜在地节省很多固定写法,尤其是对小类来说。
Scala编译器将把你放在类内部的任何不是字段的部分或者方法定义的代码,编译进主构造器。例如,你可以像这样打印输出一条除错消息:
1 class Rational(n: Int, d: Int) { 2 println("Created "+n+"/"+d) 3 }
根据这个代码,Scala编译器将把println调用放在Rational的主构造器。因此,println调用将在每次创建一个新的Rational实例时打印这条除错信息:
1 scala> new Rational(1, 2) 2 Created 1/2 3 res0: Rational = Rational@a0b0f5
6.3 重新实现toString方法
前例中当Rational实例被创建之后,解释器打印输出“Rational@a0b0f5”。解释器是通过调用Rational对象的toString方法获得的这个看上去有些好玩儿的字串。缺省情况下,Rational类继承了定义在java.lang.Object类上的toString实现,只是打印类名,一个@符号和一个十六进制数。toString的结果主要是想通过提供可以用在除错时的语句打印,日志消息,测试错误报告和解释器,除错器输出的信息来尝试对程序员提供帮助。目前toString提供的结果不会特别有用,因为它没有给出任何它被调用的Rational数值的任何线索。更有用的toString实现应该打印出Rational的分子和分母。你可以通过在Rational类里增加toString方法的方式重载:override缺省的实现,如:
1 class Rational(n: Int, d: Int) { 2 override def toString = n +"/"+ d 3 }
方法定义前的override修饰符标示了之前的方法定义被重载;第10章会更进一步说明。现在分数显示得很漂亮了,所以我们去掉了前一个版本的Rational类里面的println除错语句。你可以在解释器里测试Rational的新行为
1 scala> val x = new Rational(1, 3) 2 x: Rational = 1/3 3 scala> val y = new Rational(5, 7) 4 y: Rational = 5/7
6.4 检查先决条件
下一步,我们将把视线转向当前主构造器行为里的一些问题。如本章早些时候提到的,分数的分母不能为零。然而目前主构造器会接受把零传递给d:
1 scala> new Rational(5, 0) 2 res6: Rational = 5/0
面向对象编程的一个优点就是它允许你把数据封装在对象之内以便于你确保数据在整个生命周期中是有效的。像Rational这样的不可变对象,这就意味着你必须确保在对象创建的时候数据是有效的(并且,确保对象的确是不可变的,这样数据就不会在之后变成无效的状态)。由于零做分母对Rational来说是无效状态,因此在把零传递给d的时候,务必不能让Rational被构建出来。
解决这个问题的最好办法是为主构造器定义一个先决条件:precondition说明d必须为非零值。先决条件是对传递给方法或构造器的值的限制,是调用者必须满足的需求。一种方式是使用require方法:
1 class Rational(n: Int, d: Int) { 2 require(d != 0) 3 override def toString = n +"/"+ d 4 }
require方法带一个布尔型参数。如果传入的值为真,require将正常返回。反之,require将通过抛出IllegalArgumentException来阻止对象被构造。
6.5 添加字段
现在主构造器可以正确地执行先决条件,我们将把注意力集中到支持加法。想做到这点,我们将在类Rational上定义一个公开的add方法,它带另一个Rational做参数。为了保持Rational不可变,add方法必须不能把传入的分数加到自己身上。而是必须创建并返回一个全新的带有累加值的Rational。你或许想你可以这么写add:
1 class Rational(n: Int, d: Int) { // 编译不过 require(d != 0) 2 override def toString = n +"/"+ d 3 def add(that: Rational): Rational = new Rational(n * that.d + that.n * d, d * that.d) 4 }
很不幸,上面的代码会让编译器提示说:
1 <console>:11: error: value d is not a member of Rational new Rational(n * that.d + that.n * d, d * that.d) ˆ 2 <console>:11: error: value d is not a member of Rational new Rational(n * that.d + that.n * d, d * that.d)
尽管类参数n和d都在你的add代码可引用的范围内,但是在调用add的对象中仅能访问它们的值。因此,当你在add的实现里讲n或d的时候,编译器将很高兴地提供给你这些类参数的值。但绝对不会让你使用that.n或that.d,因为that并不指向add被调用的Rational对象。要想访问that的n和d,需要把它们放在字段中。代码6.1展示了如何把这些字段加入类Rational。
在代码6.1展示的Rational版本里,我们增加了两个字段,分别是numer和denom,并用类参数n和d初始化它们。我们还改变了toString和add的实现,让它们使用字段,而不是类参数。类Rational的这个版本能够编译通过,可以通过分数的加法测试它:
1 class Rational(n: Int, d: Int) { 2 require(d != 0) 3 val numer: Int = n 4 val denom: Int = d 5 override def toString = numer+"/"+denom 6 def add(that: Rational): Rational = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) 7 }
1 scala> val oneHalf = new Rational(1, 2) 2 oneHalf: Rational = 1/2 3 scala> val twoThirds = new Rational(2, 3) 4 twoThirds: Rational = 2/3 5 scala> oneHalf add twoThirds 6 res0: Rational = 7/6
另一件之前不能而现在可以做的事是在对象外面访问分子和分母。只要访问公共的numer和denom字段即可:
1 scala> val r = new Rational(1, 2) 2 r: Rational = 1 / 2 3 scala> r.numer 4 res7: Int = 1 5 scala> r.denom 6 res8: Int = 2
6.6 自指向
关键字this指向当前执行方法被调用的对象实例,或者如果使用在构造器里的话,就是正被构建的对象实例。例如,我们考虑添加一个方法,lessThan,来测试给定的分数是否小于传入的参数:
1 def lessThan(that: Rational) = this.numer * that.denom < that.numer * this.denom
这里,this.numer指向lessThan被调用的那个对象的分子。你也可以去掉this前缀而只是写numer;着两种写法是相同的。 举一个不能缺少this的例子,考虑在Rational类里添加max方法返回指定分数和参数中的较大者:
1 def max(that: Rational) = if (this.lessThan(that)) that else this
这里,第一个this是冗余的,你写成(lessThan(that))也是一样的。但第二个this表示了当测试为假的时候的方法的结果;如果你省略它,就什么都返回不了了。
6.7 从构造器
有些时候一个类里需要多个构造器。Scala里主构造器之外的构造器被称为从构造器:auxiliary constructor。比方说,分母为1的分数只写分子的话就更为简洁。如,对于5/1来说,可以只是写成5。因此,如果不是写成Rational(5, 1),客户程序员简单地写成Rational(5)或许会更好看一些。这就需要给Rational添加一个只带一个参数,分子,的从构造器并预先设定分母为1。代码6.2展示了应该有的样子
1 class Rational(n: Int, d: Int) { 2 require(d != 0) 3 val numer: Int = n 4 val denom: Int = d 5 def this(n: Int) = this(n, 1) 6 override def toString = numer+"/"+denom 7 def add(that: Rational): Rational = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) 8 }
Scala的从构造器开始于def this(...)。Rational的从构造器主体几乎完全是调用主构造器,直接传递了它的唯一的参数,n,作为分子和1作为分母。输入下列代码到解释器里可以实际看到从构造器的效果:
1 scala> val y = new Rational(3) 2 y: Rational = 3/1
Scala里的每一个从构造器的第一个动作都是调用同一个类里面其他的构造器。换句话说就是,每个Scala类里的每个从构造器都是以“this(...)”形式开头的。被调用的构造器既可以是主构造器(好像Rational这个例子),也可以是从文本上来看早于调用构造器的其它从构造器。这个规则的根本结果就是每一个Scala的构造器调用终将结束于对类的主构造器的调用。因此主构造器是类的唯一入口点。
若你熟悉Java,你或许会奇怪为什么Scala构造器的规矩比Java的还要大。Java里,构造器的第一个动作必须要么调用同类里的另一个构造器,要么直接调用超类的构造器。Scala的类里面,只有主构造器可以调用超类的构造器。Scala里更严格的限制实际上是权衡了更高的简洁度和与Java构造器相比的简易性所付出的代价之后作出的设计。超类,构造器调用和继承交互的细节将在第10章里解释。
6.8 私有字段和方法
上一个版本的Rational里,我们只是分别用n初始化了numer,用d初始化了denom。结果,Rational的分子和分母可能比它所需要的要大。例如分数66/42,可以更约简化为相同的最简形式,11/7,但Rational的主构造器当前并不做这个工作:
1 scala> new Rational(66, 42) 2 res15: Rational = 66/42
要想对分数进行约简化,需要把分子和分母都除以最大公约数:greatest common divisor。如:66和42的最大公约数是6。(另一种说法就是,6是能够除尽66和42的最大的整数。)66/42的分子和分母都除以6就产生它的最简形式,11/7。代码6.3展示了如何做到这点:
class Rational(n: Int, d: Int) { require(d != 0) private val g = gcd(n.abs, d.abs) val numer = n / g val denom = d / g def this(n: Int) = this(n, 1) def add(that: Rational): Rational = new Rational( numer * that.denom + that.numer * denom, denom * that.denom ) override def toString = numer+"/"+denom private def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b) }
这个版本的Rational里,我们添加了私有字段,g,并修改了numer和denom的初始化器(初始化器:initializer是初始化变量,例如初始化numer的“n / g”,的代码)。因为g是私有的,它只能在类的主体之内,而不能在外部被访问。我们还添加了一个私有方法,gcd,用来计算传入的两个Int的最大公约数。比方说,gcd(12, 8)是4。正如你在4.1节中看到的,想让一个字段或方法私有化你只要把private关键字放在定义的前面。私有的“助手方法”gcd的目的是把类的其它部分,这里是主构造器,需要的代码分离出来。为了确保g始终是正的,我们传入n和d的绝对值,调用abs即可获得任意整数的绝对值。
Scala编译器将把Rational的三个字段的初始化代码依照它们在源代码中出现的次序放入主构造器。所以g的初始化代码,gcd(n.abs, d.abs),将在另外两个之前执行,因为它在源文件中出现得最早。g将被初始化为类参数,n和d,的绝对值的最大公约数。然后再被用于numer和denom的初始化。通过把n和d整除它们的最大公约数,g,每个Rational都将被构造成它的最简形式
1 scala> new Rational(66, 42) 2 res24: Rational = 11/7
6.9 定义操作符
Rational加法的当前实现仅就完成功能来讲是没问题的,但它可以做得更好用。你或许会问你自己为什么对于整数或浮点数你可以写成:
x + y
但是如果是分数就必须写成:
x.add(y)
或至少是:
x add y
没有合理的解释为什么就必须是这样的。分数和别的数应该是一样的。数学的角度上看他们甚至比,唔,浮点数,更自然。为什么就不能使用自然的数学操作符呢?Scala里面你做得到。本章后续部分,我们会告诉你怎么做。 第一步是用通常的数学的符号替换add方法。这可以直接做到,因为Scala里+是合法的标识符。我们可以用+定义方法名。既然已经到这儿了,你可以同样实现一个*方法以实现乘法,结果展示在代码6.4中:
与以往一样,在最后输入的那行里的语法格式相等于一个方法调用。你也能这么写:
1 scala> x.+(y) 2 res33: Rational = 7/6
1 class Rational(n: Int, d: Int) { 2 require(d != 0) 3 private val g = gcd(n.abs, d.abs) 4 val numer = n / g 5 val denom = d / g 6 def this(n: Int) = this(n, 1) 7 def +(that: Rational): Rational = 8 new Rational( 9 numer * that.denom + that.numer * denom, 10 denom * that.denom 11 ) 12 def *(that: Rational): Rational = 13 new Rational(numer * that.numer, denom * that.denom) 14 override def toString = numer+"/"+denom 15 private def gcd(a: Int, b: Int): Int = 16 if (b == 0) a else gcd(b, a % b)}
有了这种方式定义的Rational类,你现在可以这么写了:
1 scala> val x = new Rational(1, 2) 2 x: Rational = 1/2 3 scala> val y = new Rational(2, 3) 4 y: Rational = 2/3 5 scala> x + y 6 res32: Rational = 7/6
另外一件要提的是基于5.8节中提到的Scala的操作符优先级规则,Rational里面的*方法要比+方法绑定得更结实。或者说,Rational涉及到+和*操作的表达式会按照预期的方式那样表现。例如,x + x * y会当作x + (x * y)而不是(x + x) * y:
6.10 Scala的标识符
现在你已经看到了Scala里两种构成标识符的方式:字母数字式和操作符。Scala在构成标识符方面有非常灵活的规则。除了这两种之外你会看到还有其它的两种。本节将说明所有的这四种标识符构成方式
字母数字标识符:alphanumeric identifier起始于一个字母或下划线,之后可以跟字母,数字,或下划线。‘$’字符也被当作是字母,但是被保留作为Scala编译器产生的标识符之用。用户程序里的标识符不应该包含‘$’字符,尽管能够编译通过;但是这样做有可能导致与Scala编译器产生的标识符发生名称冲撞。
Scala遵循Java的驼峰式标识符习俗,例如toString和HashSet。尽管下划线在标识符内是合法的,但在Scala程序里并不常用,部分原因是为了保持与Java一致,同样也由于下划线在Scala代码里有许多其它非标识符用法。因此,最好避免使用像to_string,__init__,或name_这样的标识符。字段,方法参数,本地变量,还有函数的驼峰式名称,应该以小写字母开始,如:length,flatMap,还有s。类和特质的驼峰式名称应该以大写字母开始,如:BigInt,List,还有UnbalancedTreeMap。
Scala与Java的习惯不一致的地方在于常量名。Scala里,constant这个词并不等同于val。尽管val在被初始化之后的确保持不变,但它还是个变量。比方说,方法参数是val,但是每次方法被调用的时候这些val都可以代表不同的值。而常量更持久。比方说,scala.Math.Pi被定义为很接近实数π的双精度值,表示圆周和它的直径的比值。这个值不太可能改变,因此Pi显然是个常量。你还可以用常数去给一些你代码里作为幻数:magic number要用到的值一个名字:文本值不具备解释能力,如果出现在多个地方将会变得极度糟糕。你还可能会需要定义用在模式匹配里的常量,用例将在15.2节中说明。Java里,习惯上常量名全都是大写的,用下划线分隔单词,如MAX_VALUE或PI。Scala里,习惯只是第一个字母必须大写。因此,Java风格的常量名,如X_OFFSET,在Scala里也可以用,但是Scala的惯例是常数也用驼峰式风格,如XOffset。
操作符标识符:operator identifier由一个或多个操作符字符组成。操作符字符是一些如+,:,?,~或#的可打印的ASCII字符以下是一些操作符标识符的例子:
+ ++ ::: <?> :->
Scala编译器将内部“粉碎”操作符标识符以转换成合法的内嵌‘$’的Java标识符。例如,标识符:->将被内部表达为$colon$minus$greater。若你想从Java代码访问这个标识符,就应使用这个内部表达。
Scala里的操作符标识符可以变得任意长,因此在Java和Scala间有一些小差别。Java里,输入x<-y将会被拆分成四个词汇符号,所以写成x < - y也没什么不同。Scala里,<-将被作为一个标识符拆分,而得到x <- y。如果你想要得到第一种解释,你要在‘<’和‘-’字符间加一个空格。这大概不会是实际应用中的问题,因为没什么人会在Java里写x<-y的时候不注意加空格或括号的。
混合标识符:mixed identifier由字母数字组成,后面跟着下划线和一个操作符标识符。例如,unary_+被用做定义一元的‘+’操作符的方法名。或者,myvar_=被用做定义赋值操作符的方法名。多说一句,混合标识符格式myvar_=是由Scala编译器产生的用来支持属性:property的
文本标识符:literal identifier是用反引号`...`包括的任意字串。如:`x` `<clinit>` `yield`
6.11 方法重载
回到类Rational上来。在最近一次改变之后,你可以在分数上用自然的风格做加法和乘法。但别忘了还有混合运算。例如,你不能把一个分数和一个整数乘在一起,因为‘*’的操作数只能是分数。所以对于分数r你不能写r * 2。而必须写成r * new Rational(2),
为了让Rational用起来更方便,可以在类上增加能够执行分数和整数之间的加法和乘法的新方法。既然已经到这里了,还可以再加上减法和除法。
6.12 隐式转换