本章的重點在於定義函數式對象,也就是說,沒有任何可變狀態的對象的類。作為運行的例子,我們將創造若干把分數作為不可變對象建模的類的變體。在這過程中,我們會展示給你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 隱式轉換