最近,讀者的反饋讓我意識到在制作本系列的過程中我遺漏了 Scala 的語言的一個重要方面:Scala 的包和訪問修飾符功能。所以在研究該語言的函數性元素 apply 機制前,我將先介紹包和訪問修飾符。
打包
為了有助於隔離代碼,使其不會相互沖突,Java™ 代碼提供了 package 關鍵詞,由此創建了一個詞法命名空間,用以聲明類。本質上,將類 Foo 放置到名為 com.tedneward.util 包中就將正式類名修改成了 com.tedneward.util.Foo;同理,必須按該方法引用類。如果沒有,Java 編程人員會很快指出,他們會 import 該包,避免鍵入正式名的麻煩。的確如此,但這僅意味著根據正式名引用類的工作由編譯器和字節碼完成。快速浏覽一下 javap 的輸出,這點就會很明了。
然而,Java 語言中的包還有幾個特殊的要求:一定要在包所作用的類所在的 .java 文件的頂端聲明包(在將注釋應用於包時,這一點會引發很嚴重的語言問題);該聲明的作用域為整個文件。這意味著兩個跨包進行緊密耦合的類一定要在跨文件時分離,這會致使兩者間的緊密耦合很容易被忽略。
Scala 在打包方面所采取的方法有些不同,它結合使用了 Java 語言的 declaration 方法和 C# 的 scope(限定作用域)方法。了解了這一點,Java 開發人員就可以使用傳統的 Java 方法並將 package 聲明放在 .scala 文件的頂部,就像普通的 Java 類一樣;包聲明的作用域為整個文件,就像在 Java 代碼中一樣。而 Scala 開發人員則可以使用 Scala 的包 “(scoping)限定作用域” 方法,用大括號限制 package 語句的作用域,如清單 1 所示:
清單 1. 簡化的打包
package com
{
package tedneward
{
package scala
{
package demonstration
{
object App
{
def main(args : Array[String]) : Unit =
{
System.out.println("Howdy, from packaged code!")
args.foreach((i) => System.out.println("Got " + i) )
}
}
}
}
}
}
這個代碼有效地聲明了類 App,或者更確切的說是一個稱為 com.tedneward.scala.demonstration.App 的單個類。注意 Scala 還允許用點分隔包名,所以清單 1 中的代碼可以更簡潔,如清單 2 所示:
清單 2. 簡化了的打包(redux)
package com.tedneward.scala.demonstration
{
object App
{
def main(args : Array[String]) : Unit =
{
System.out.println("Howdy, from packaged code!")
args.foreach((i) => System.out.println("Got " + i) )
}
}
}
用哪一種樣式看起來都比較合適,因為它們都編譯出一樣的代碼構造(Scala 將繼續編譯並和 javac 一樣在聲明包的子目錄中生成 .class 文件)。
導入
與包相對的當然就是 import 了,Scala 使用它將名稱放入當前詞法名稱空間。本系列的讀者已經在此前的很多例子中見到過 import 了,但現在我將指出一些讓 Java 開發人員大吃一驚的 import 的特性。
首先,import 可以用於客戶機 Scala 文件內的任何地方,並非只可以用在文件的頂部,這樣就有了作用域的關聯性。因此,在清單 3 中,java.math.BigInteger 導入的作用域被完全限定到了在 App 對象內部定義的方法,其他地方都不行。如果 mathfun 內的其他類或對象要想使用 java.math.BigInteger,就需要像 App 一樣導入該類。如果 mathfun 的幾個類都想使用 java.math.BigInteger,可以在 App 的定義以外的包級別導入該類,這樣在包作用域內的所有類就都導入 BigInteger 了。
清單 3. 導入的作用域
package com
{
package tedneward
{
package scala
{
// ...
package mathfun
{
object App
{
import java.math.BigInteger
def factorial(arg : BigInteger) : BigInteger =
{
if (arg == BigInteger.ZERO) BigInteger.ONE
else arg multiply (factorial (arg subtract BigInteger.ONE))
}
def main(args : Array[String]) : Unit =
{
if (args.length > 0)
System.out.println("factorial " + args(0) +
" = " + factorial(new BigInteger(args(0))))
else
System.out.println("factorial 0 = 1")
}
}
}
}
}
}
不只如此,Scala 還不區分高層成員和嵌套成員,所以您不僅可以使用 import 將嵌套類型的成員置於詞法作用域中,其他任何成員均可;例如,您可以通過導入 java.math.BigInteger 內的所有名稱,使對 ZERO 和 ONE 的限定了作用域的引用縮小為清單 4 中的名稱引用:
清單 4. 靜態導入
package com
{
package tedneward
{
package scala
{
// ...
package mathfun
{
object App
{
import java.math.BigInteger
import BigInteger._
def factorial(arg : BigInteger) : BigInteger =
{
if (arg == ZERO) ONE
else arg multiply (factorial (arg subtract ONE))
}
def main(args : Array[String]) : Unit =
{
if (args.length > 0)
System.out.println("factorial " + args(0) +
" = " + factorial(new BigInteger(args(0))))
else
System.out.println("factorial 0 = 1")
}
}
}
}
}
}
您可以使用下劃線(還記得 Scala 中的通配符吧?)有效地告知 Scala 編譯器 BigInteger 內的所有成員都需要置入作用域。由於 BigInteger 已經被先前的導入語句導入到作用域中,因此無需顯式地使用包名限定類名。實際上,可以將所有這些都結合到一個語句中,因為 import 可以同時導入多個目標,目標間用逗號隔開(如清單 5 所示):
清單 5. 批量導入
package com
{
package tedneward
{
package scala
{
// ...
package mathfun
{
object App
{
import java.math.BigInteger, BigInteger._
def factorial(arg : BigInteger) : BigInteger =
{
if (arg == ZERO) ONE
else arg multiply (factorial (arg subtract ONE))
}
def main(args : Array[String]) : Unit =
{
if (args.length > 0)
System.out.println("factorial " + args(0) +
" = " + factorial(new BigInteger(args(0))))
else
System.out.println("factorial 0 = 1")
}
}
}
}
}
}
這樣您可以節省一兩行代碼。注意這兩個導入過程不能結合:先導入 BigInteger 類本身,再導入該類中的各種成員。
也可以使用 import 來引入其他非常量的成員。例如,考慮一下清單 6 中的數學工具庫(或許不一定有什麼價值):
清單 6. Enron 的記帳代碼
package com
{
package tedneward
{
package scala
{
// ...
package mathfun
{
object BizarroMath
{
def bizplus(a : Int, b : Int) = { a - b }
def bizminus(a : Int, b : Int) = { a + b }
def bizmultiply(a : Int, b : Int) = { a / b }
def bizdivide(a : Int, b : Int) = { a * b }
}
}
}
}
}
使用這個庫會越來越覺得麻煩,因為每請求它的一個成員,都需要鍵入 BizarroMath,但是 Scala 允許將 BizarroMath 的每一個成員導入最高層的詞法空間,因此簡直就可以把它們當成全局函數來使用(如清單 7所示):
清單 7. 計算 Enron的開支
package com
{
package tedneward
{
package scala
{
package demonstration
{
object App2
{
def main(args : Array[String]) : Unit =
{
import com.tedneward.scala.mathfun.BizarroMath._
System.out.println("2 + 2 = " + bizplus(2,2))
}
}
}
}
}
}
還有其他的一些構造很有趣,它們允許 Scala 開發人員寫出更自然的 2 bizplus 2,但是這些內容本文不予討論(想了解 Scala 潛在的可以用於其他用途的特性的讀者可以看一下 Odersky、Spoon 和 Venners 所著的 Programming in Scala 中談到的 Scala implicit 構造)。
訪問
打包(和導入)是 Scala 封裝的一部分,和在 Java 代碼中一樣,在 Scala 中,打包很大一部分在於以選擇性方式限定訪問特定成員的能力 — 換句話說,在於 Scala 將特定成員標記為 “公有(public)”、“private(私有)” 或介於兩者之間的成員的能力。
Java 語言有四個級別的訪問:公有(public)、私有(private)、受保護的(protected )和包級別(它沒有任何關鍵詞)訪問。Scala:
廢除了包級別的限制(在某種程度上)
默認使用 “公有”
指定 “私有” 表示 “只有此作用域可訪問”
相反,Scala 定義 “protected” 的方式與在 Java 代碼中不同;Java protected 成員對於子類和在其中定義成員的包來說是可訪問的,Scala 中則僅有子類可訪問。這意味著 Scala 版本的 protected 限制性要比 Java 版本更嚴格(雖然按理說更加直觀)。
然而,Scala 真正區別於 Java 代碼的地方是 Scala 中的訪問修飾符可以用包名來 “限定”,用以表明直到 哪個訪問級別才可以訪問成員。例如,如果 BizarroMath 包要將成員訪問權限授權給同一包中的其他成員(但不包括子類),可以用清單 8 中的代碼來實現:
清單 8. Enron 的記帳代碼
package com
{
package tedneward
{
package scala
{
// ...
package mathfun
{
object BizarroMath
{
def bizplus(a : Int, b : Int) = { a - b }
def bizminus(a : Int, b : Int) = { a + b }
def bizmultiply(a : Int, b : Int) = { a / b }
def bizdivide(a : Int, b : Int) = { a * b }
private[mathfun] def bizexp(a : Int, b: Int) = 0
}
}
}
}
}
注意此處的 private[mathfun] 表達。本質上,這裡的訪問修飾符是說該成員直到 包 mathfun 為止都是私有的;這意味著包 mathfun 的任何成員都有權訪問 bizexp,但任何包以外的成員都無權訪問它,包括子類。
這一點的強大意義就在於任何包都可以使用 “private” 或者 “protected” 聲明甚至 com(乃至 _root_,它是根名稱空間的別名,因此本質上 private[_root_] 等效於 “public” 同)進行聲明。這使得 Scala 能夠為訪問規范提供一定程度的靈活性,遠遠高於 Java 語言所提供的靈活性。
實際上,Scala 提供了一個更高程度的訪問規范:對象私有 規范,用 private[this] 表示,它規定只有被同一對象調用的成員可以訪問有關成員,其他對象裡的成員都不可以,即使對象的類型相同(這彌合了 Java 訪問規范系統中的一個缺口,這個缺口除對 Java 編程問題有用外,別無他用。)
注意訪問修飾符必須在某種程度上在 JVM 之上映射,這致使定義中的細枝末節會在從正規 Java 代碼中調用或編譯時丟失。例如,上面的 BizarroMath 示例(用 private[mathfun] 聲明的成員 bizexp)將會生成清單 9 中的類定義(當用 javap 來查看時):
Listing 9. Enron 的記帳庫,JVM 視圖
Compiled from "packaging.scala"
public final class com.tedneward.scala.mathfun.BizarroMath
extends java.lang.Object
{
public static final int $tag();
public static final int bizexp(int, int);
public static final int bizdivide(int, int);
public static final int bizmultiply(int, int);
public static final int bizminus(int, int);
public static final int bizplus(int, int);
}
在編譯的 BizarroMath 類的第二行很容易看出,bizexp() 方法被賦予了 JVM 級別的 public 訪問修飾符,這意味著一旦 Scala 編譯器結束訪問檢查,細微的 private[mathfun] 區別就會丟失。因此,對於那些要從 Java 代碼使用的 Scala 代碼,我寧願堅持傳統的 “private” 和 “public” 的定義(甚至 “protected” 的定義有時最終映射到 JVM 級別的 “public”,所有不確定的時候,請對照實際編譯的字節碼參考一下 javap,以確認其訪問級別。)
應用
在本系列上一期的文章中(“集合類型”),當談及 Scala 中的數組時(確切地說是 Array[T])我說過:“獲取數組的第 i 個元素” 實際上是 “那些名稱很有趣的方法中的一種……”。盡管當時是因為我不想深入細節,但不管怎麼說事實證明這種說法嚴格來說 是不對的。
好吧,我承認,我說謊了。
技術上講,在 Array[T] 類上使用圓括號要比使用 “名稱有趣的方法” 復雜一點;Scala 為特殊的字符序列(即那些有左右括號的序列)保留了一個特殊名稱關聯,因為它有著特殊的使用意圖 :“做”……(或按函數來說,將……“應用” 到……)。
換句話說,Scala 有一個特殊的語法(更確切一些,是一個特殊的語法關系)來代替 “應用” 操作符 “()”。更精確地說,當用 () 作為方法調用來調用所述對象時,Scala 將稱為 apply() 的方法作為調用的方法。例如,一個想充當仿函數(functor)的類(一個充當函數的對象)可以定義一個 apply 方法來提供類似於函數或方法的語義:
清單 10. 使用 Functor!
class ApplyTest
{
import org.junit._, Assert._
@Test def simpleApply =
{
class Functor
{
def apply() : String =
{
"Doing something without arguments"
}
def apply(i : Int) : String =
{
if (i == 0)
"Done"
else
"Applying... " + apply(i - 1)
}
}
val f = new Functor
assertEquals("Doing something without arguments", f() )
assertEquals("Applying... Applying... Applying... Done", f(3))
}
}
好奇的讀者會想是什麼使仿函數不同於匿名函數或閉包呢?事實證明,它們之間的關系相當明顯:標准 Scala 庫中的 Function1 類型(指包含一個參數的函數)在其定義上有一個 apply 方法。快速浏覽一些為 Scala 匿名函數生成的 Scala 匿名類,您就會明白生成的類是 Function1(或者 Function2 或 Function3,這要看該函數使用了幾個參數)的後代。
這意味著當匿名的或者命名的函數不一定適合期望設計方法時,Scala 開發人員可以創建一個 functor 類,提供給它一些初始化數據,保存在字段中,然後通過 () 執行它,無需任何通用基類(傳統的策略模式實現需要這個類):
清單 11. 使用 Functor!
class ApplyTest
{
import org.junit._, Assert._
// ...
@Test def functorStrategy =
{
class GoodAdder
{
def apply(lhs : Int, rhs : Int) : Int = lhs + rhs
}
class BadAdder(inflateResults : Int)
{
def apply(lhs : Int, rhs : Int) : Int = lhs + rhs * inflateResults
}
val calculator = new GoodAdder
assertEquals(4, calculator(2, 2))
val enronAccountant = new BadAdder(50)
assertEquals(102, enronAccountant(2, 2))
}
}
任何提供了被適當賦予了參數的 apply 方法的類,只要這些參數都按數字和類型排列了起來,它們都會在被調用時運行。
結束語
Scala 的打包、導入和訪問修飾符機制提供了傳統 Java 編程人員從未享受過的更高級的控制和封裝。例如,它們提供了導入一個對象的選擇方法的能力,使它們看起來就像全局方法一樣,而且還克服了全局方法的傳統的缺點;它們使得使用那些方法變得極其簡單,尤其是當這些方法提供了諸如本系列早期文章(“Scala 控制結構內部揭密”)引入的虛構的 tryWithLogging 函數這樣的高級功能時。
同樣,“應用” 機制允許 Scala 隱藏函數部分的執行細節,這樣,編程人員可能會不知道(或不在乎)他們正調用的東西 事實上不是一個函數,而是一個非常復雜的對象。該機制為 Scala 機制的函數特性提供了另一個方面,當然 Java 語言(或者 C# 或 C++)也提供了這個方面,但是它們提供的語法純度沒有 Scala 的高。
這就是本期的全部內容;在下一期發布前,請盡情欣賞!
本文配套源碼