# 實戰 Groovy: 使用閉包、ExpandoMetaClass 和類別進行元編程
_隨心所欲添加方法_
進入到 Groovy 風格的元編程世界。在運行時向類動態添加方法的能力 — 甚至 Java? 類以及 `final` Java 類 — 強大到令人難以置信。不管是用于生產代碼、單元測試或介于兩者之間的任何內容,即使是最缺乏熱情的 Java 開發人員也會對 Groovy 的元編程能力產生興趣。
人們一直以來都認為 Groovy 是一種面向 JVM 的_動態_ 編程語言。在這期 [_實戰 Groovy_](http://www.ibm.com/developerworks/cn/java/j-pg/) 文章中,您將了解_元編程_— Groovy 在運行時向類動態添加新方法的能力。它的靈活性遠遠超出了標準 Java 語言。通過一系列代碼示例(都可以通過 [下載](#download) 獲得),將認識到元編程是 Groovy 的最強大、最實用的特性之一。
## 建模
程序員的工作就是使用軟件建模真實的世界。對于真實世界中存在的簡單域 — 比如具有鱗片或羽毛的動物通過產卵繁育后代,而具有毛皮的動物則通過產仔繁殖 — 可以很容易地使用軟件對行為進行歸納,如清單 1 所示:
##### 清單 1\. 使用 Groovy 對動物進行建模
```
class ScalyOrFeatheryAnimal{
ScalyOrFeatheryAnimal layEgg(){
return new ScalyOrFeatheryAnimal()
}
}
class FurryAnimal{
FurryAnimal giveBirth(){
return new FurryAnimal()
}
}
```
## 關于本系列
Groovy 是在 Java 平臺上運行的一種現代編程語言。它能夠與現有 Java 代碼無縫集成,同時引入了各種生動的新特性,比如閉包和元編程。簡單來講,Groovy 是 Java 語言的 21 世紀版本。
將任何新工具整合到開發工具包中的關鍵是知道何時使用它以及何時將它留在工具包中。Groovy 的功能可以非常強大,但惟一的條件是正確應用于適當的場景。因此,[_實戰 Groovy_](http://www.ibm.com/developerworks/cn/java/j-pg/) 系列將探究 Groovy 的實際應用,以便幫助您了解何時以及如何成功使用它們。
不幸的是,真實的世界總是充滿了例外和極端情況 — 鴨嘴獸既有皮毛,又通過產卵繁殖后代。我們精心考慮的每一項軟件抽象幾乎都存在與之相反的方面。
如果用來建模域的軟件語言由于太過死板而無法處理不可避免的例外情況,那么最終的情形就像是受雇于一個小官僚機構的固執的公務員 — “對不起,Platypus 先生,如果要想我們的系統可以跟蹤到您的話,您必須會生孩子。”
另一方面,Groovy 之類的動態語言為您提供了靈活性,使您能夠更加準確地使用軟件建模現實世界,而不是預先作出假設(并且通常是無效的),讓現實向您妥協。如果 `Platypus` 類需要一個 `layEgg()` 方法,Groovy 可以滿足要求,如清單 2 所示:
##### 清單 2\. 動態添加 `layEgg()` 方法
```
Platypus.metaClass.layEgg = {->
return new FurryAnimal()
}
def baby = new Platypus().layEgg()
```
如果覺得這里舉的有關動物的例子有些淺顯,那么考慮 Java 語言中最常用的一個類:`String`。
* * *
## Groovy 為 `java.lang.String` 提供的新方法
使用 Groovy 的樂趣之一就在于它添加到 `java.lang.String` 中的新方法。`padRight()` 和 `reverse()` 等方法提供了簡單的 `String` 轉換,如清單 3 所示。(有關 GDK 添加到 `String` 的所有新方法的列表的鏈接,見 [參考資料](#resources)。正如 GDK 在其首頁中所說,“本文檔描述了添加到 JDK 并更具 groovy 特征的方法。”)
##### 清單 3\. Groovy 添加到 `String` 的方法
```
println "Introduction".padRight(15, ".")
println "Introduction".reverse()
//output
Introduction...
noitcudortnI
```
但是添加到 `String` 的方法并不僅限于簡單的功能。如果 `String` 是一個組織良好的 URL,那么只需一行代碼,您就可以將 `String` 轉換為 `java.net.URL` 并返回 HTTP GET 請求的結果,如清單 4 所示:
##### 清單 4\. 發出 HTTP GET 請求
```
println "http://thirstyhead.com".toURL().text
//output
<html>
<head>
<title>ThirstyHead: Training done right.</title>
<!-- snip -->
```
再舉一個例子,運行一個本地 shell 就像發出遠程網絡調用那么簡單。一般情況下我將在命令提示中輸入 `ifconfig en0` 以檢查網卡的 TCP/IP 設置。(如果您使用的是 Windows? 而不是 Mac OS X 或 Linux?,那么嘗試使用 `ipconfig`)。在 Groovy 中,我可以通過編程的方式完成同樣的事情,參見清單 5:
##### 清單 5\. 在 Groovy 中發出一個 shell 命令
```
println "ifconfig en0".execute().text
//output
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether 00:17:f2:cb:bc:6b
media: autoselect status: inactive
//snip
```
我并沒有說 Groovy 的優點在于您_不能_ 使用 Java 語言做同樣的事情。您當然可以。Groovy 的優點在于這些方法似乎可以直接添加到 `String` 類 — 這絕非易事,因為 `String` 是 `final` 類。(稍后將詳細討論這點)。清單 6 展示了 Java 中的相應內容 `String.execute().text`:
##### 清單 6\. 使用 Java 語言發出 shell 命令
```
Process p = new ProcessBuilder("ifconfig", "en0").start();
BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line = br.readLine();
while(line != null){
System.out.println(line);
line = br.readLine();
}
```
這看上去有點像在機動車輛管理局的各個窗口之間輾轉,不是嗎?“對不起,先生,要查看您請求的 `String`,首先需要去別處獲得一個 `BufferedReader`。”
是的,您可以構建方便的方法和實用類來幫助將這個問題抽象出來,但是惟一的 `com.mycompany.StringUtil` 替代方法就是使用一個類來代替將方法直接添加到所屬位置的行為:`String` 類。(當然就是 `Platypus.layEgg()`!)
那么 Groovy 究竟如何做 — 將新方法添加到無法擴展的類,或直接進行修改?要理解這一點,需要了解 _closures_ 和 `ExpandoMetaClass`。
* * *
## 閉包和 `ExpandoMetaClass`
Groovy 提供了一種無害的但功能強大的語言特性 — 閉包 — 如果沒有它的話,鴨嘴獸將永遠無法下蛋。簡單來說,閉包就是指定的一段可執行代碼。它是一個未包含在類中的方法。清單 7 演示了一個簡單閉包:
##### 清單 7\. 一個簡單閉包
```
def shout = {src->
return src.toUpperCase()
}
println shout("Hello World")
//output
HELLO WORLD
```
擁有一個獨立的方法當然很棒,但是與將方法放入到現有類的能力相比,還是有些遜色。考慮清單 8 中的代碼,其中并未創建接受 `String` 作為參數的方法,相反,我將方法直接添加到 `String` 類:
##### 清單 8\. 將 `shout` 方法添加到 `String`
```
String.metaClass.shout = {->
return delegate.toUpperCase()
}
println "Hello MetaProgramming".shout()
//output
HELLO METAPROGRAMMING
```
未包含任何參數的 `shout()` 閉包被添加到 `String` 的 `ExpandoMetaClass` (EMC) 中。每個類 — 包括 Java 和 Groovy — 都包含在一個 EMC 中,EMC 將攔截對它的方法調用。這意味著即使 `String` 為 `final`,仍然可以將方法添加到其 EMC 中。因此,現在看上去仿佛 `String` 有一個 `shout()` 方法。
由于 Java 語言中不存在這種關系,因此 Groovy 必須引入一個新的概念:_委托(delegate)_。`delegate` 是 EMC 所圍繞的類。
首先了解到方法調用包含在 EMC 中,然后了解了 `delegate`,您就可以執行任何有趣的操作。比如,注意清單 9 然后重新定義 `String` 的 `toUpperCase()` 方法:
##### 清單 9\. 重新定義 `toUpperCase()` 方法
```
String.metaClass.shout = {->
return delegate.toUpperCase()
}
String.metaClass.toUpperCase = {->
return delegate.toLowerCase()
}
println "Hello MetaProgramming".shout()
//output
hello metaprogramming
```
這個操作看上去仍然有些不嚴謹(甚至有些危險!)。盡管現實中很少需要修改 `toUpperCase()` 方法的行為,但是想象一下為代碼單元測試帶來的好處?元編程提供了快速、簡單的方法,使潛在的隨機行為具有了必然性。比如,清單 10 演示了 `Math` 類的靜態 `random()` 方法被重寫:
##### 清單 10\. 重寫 `Math.random()` 方法
```
println "Before metaprogramming"
3.times{
println Math.random()
}
Math.metaClass.static.random = {->
return 0.5
}
println "After metaprogramming"
3.times{
println Math.random()
}
//output
Before metaprogramming
0.3452
0.9412
0.2932
After metaprogramming
0.5
0.5
0.5
```
現在,想像一下對發出開銷較高的 SOAP 調用的類進行單元測試。無需創建接口和去掉整個模擬對象的存根 — 您可以有選擇地重寫方法并返回一個簡單的模擬響應。(您將在下一小節看到使用 Groovy 實現單元測試和模擬的例子)。
Groovy 元編程是一種運行時行為 — 這個行為從程序啟動一直持續到程序運行。但是如果希望對元編程進行更多的顯示該怎么做(對于編寫單元測試尤其重要)?在下一小節,您將了解揭秘元編程的秘密。
* * *
## 解密元編程
清單 11 封裝了我在 `GroovyTestCase` 中編寫的演示代碼,這樣就可以更加嚴格地對輸出進行測試。(參見 “[實戰 Groovy: 用 Groovy 更迅速地對 Java 代碼進行單元測試](http://www.ibm.com/developerWorks/cn/java/j-pg11094/)” 了解更多有關使用 `GroovyTestCase` 的信息)。
##### 清單 11\. 使用單元測試分析元編程
```
class MetaTest extends GroovyTestCase{
void testExpandoMetaClass(){
String message = "Hello"
shouldFail(groovy.lang.MissingMethodException){
message.shout()
}
String.metaClass.shout = {->
delegate.toUpperCase()
}
assertEquals "HELLO", message.shout()
String.metaClass = null
shouldFail{
message.shout()
}
}
}
```
在命令提示中輸入 `groovy MetaTest` 以運行該測試。
注意,只需將 `String.metaClass` 設置為 `null`,就可以取消元編程。
但是,如果您不希望 `shout()` 方法出現在所有 `String` 中該怎么辦呢?您可以僅調整單一實例的 EMC(而不是類),如清單 12 所示:
##### 清單 12\. 對單個實例進行元編程
```
void testInstance(){
String message = "Hola"
message.metaClass.shout = {->
delegate.toUpperCase()
}
assertEquals "HOLA", message.shout()
shouldFail{
"Adios".shout()
}
}
```
如果準備一次性添加或重寫多個方法,清單 13 展示了如何以塊的方式定義新方法:
##### 清單 13\. 一次性對多個方法進行元編程
```
void testFile(){
File f = new File("nonexistent.file")
f.metaClass{
exists{-> true}
getAbsolutePath{-> "/opt/some/dir/${delegate.name}"}
isFile{-> true}
getText{-> "This is the text of my file."}
}
assertTrue f.exists()
assertTrue f.isFile()
assertEquals "/opt/some/dir/nonexistent.file", f.absolutePath
assertTrue f.text.startsWith("This is")
}
```
注意,我再也不關心文件是否存在于文件系統中。我可以將它發送給這個單元測試中的其他類,并且它表現得像一個真正的文件。當 `f` 變量在測試結束時超出范圍之后,還會執行定制行為。
盡管 `ExpandoMetaClass` 十分強大,但是 Groovy 提供了另一種元編程方法,使用了它獨有的一組功能:_類別(category)_。
* * *
## 類別和 `use` 塊
解釋 `Category` 的最佳方法就是了解它的實際運行。清單 14 演示了使用 `Category` 來將 `shout()` 方法添加到 `String`:
##### 清單 14\. 使用一個 `Category` 進行元編程
```
class MetaTest extends GroovyTestCase{
void testCategory(){
String message = "Hello"
use(StringHelper){
assertEquals "HELLO", message.shout()
assertEquals "GOODBYE", "goodbye".shout()
}
shouldFail{
message.shout()
"foo".shout()
}
}
}
class StringHelper{
static String shout(String self){
return self.toUpperCase()
}
}
```
如果曾經從事過 Objective-C 開發,那么應當對這個技巧感到熟悉。`StringHelper``Category` 是一個普通類 — 它不需要擴展特定的父類或實現特殊的接口。要向類型為 `T` 的特定類添加新方法,只需定義一個靜態方法,它接受類型 `T` 作為第一個參數。由于 `shout()` 是一個接受 `String` 作為第一個參數的靜態方法,因此所有封裝到 `use` 塊中的 `String` 都獲得了一個 `shout()` 方法。
那么,什么時候應該選擇 `Category` 而不是 EMC?EMC 允許您將方法添加到某個類的單一實例或所有實例中。可以看到,定義 `Category` 允許您將方法添加到_特定_ 實例中 — 只限于 `use` 塊內部的實例。
雖然 EMC 允許您動態定義新行為,然而 `Category` 允許您將行為保存到獨立的類文件中。這意味著您可以在不同的情況下使用它:單元測試、生產代碼,等等。定義單獨類的開銷在重用性方面獲得了回報。
清單 15 演示了對同一個 `use` 塊同時使用 `StringHelper` 和新創建的 `FileHelper`:
##### 清單 15\. 在 `use` 塊中使用多個類別
```
class MetaTest extends GroovyTestCase{
void testFileWithCategory(){
File f = new File("iDoNotExist.txt")
use(FileHelper, StringHelper){
assertTrue f.exists()
assertTrue f.isFile()
assertEquals "/opt/some/dir/iDoNotExist.txt", f.absolutePath
assertTrue f.text.startsWith("This is")
assertTrue f.text.shout().startsWith("THIS IS")
}
assertFalse f.exists()
shouldFail(java.io.FileNotFoundException){
f.text
}
}
}
class StringHelper{
static String shout(String self){
return self.toUpperCase()
}
}
class FileHelper{
static boolean exists(File f){
return true
}
static String getAbsolutePath(File f){
return "/opt/some/dir/${f.name}"
}
static boolean isFile(File f){
return true
}
static String getText(File f){
return "This is the text of my file."
}
}
```
但是有關類別的最有趣的一點是它們的實現方式。EMC 需要使用閉包,這意味著您只能在 Groovy 中實現它們。由于類別僅僅是包含靜態方法的類,因此可以用 Java 代碼進行定義。事實上,可以在 Groovy 中重用現有的 Java 類 — 對元編程來說總是含義不明的類。
清單 16 演示了使用來自 Jakarta Commons Lang 包(見 [參考資料](#resources))的類進行元編程。`org.apache.commons.lang.StringUtils` 中的所有方法都一致地遵守 `Category` 模式 — 靜態方法接受 `String` 作為第一個參數。這意味著可以使用現成的 `StringUtils` 類作為 `Category`。
##### 清單 16\. 使用 Java 類進行元編程
```
import org.apache.commons.lang.StringUtils
class CommonsTest extends GroovyTestCase{
void testStringUtils(){
def word = "Introduction"
word.metaClass.whisper = {->
delegate.toLowerCase()
}
use(StringUtils, StringHelper){
//from org.apache.commons.lang.StringUtils
assertEquals "Intro...", word.abbreviate(8)
//from the StringHelper Category
assertEquals "INTRODUCTION", word.shout()
//from the word.metaClass
assertEquals "introduction", word.whisper()
}
}
}
class StringHelper{
static String shout(String self){
return self.toUpperCase()
}
}
```
輸入 `groovy -cp /jars/commons-lang-2.4.jar:. CommonsTest.groovy` 以運行測試(當然,您需要修改在系統中保存 JAR 的路徑)。
* * *
## 元編程和 REST
為了不讓您產生元編程_只對_ 單元測試有用的誤解,下面給出了最后一個例子。回憶一下 “[實戰 Groovy:構建和解析 XML](http://www.ibm.com/developerworks/cn/java/j-pg05199/)” 中可以預報當天天氣情況的 RESTful Yahoo! Web 服務。通過將上述文章中的 `XmlSlurper` 技巧與本文的元編程技巧結合起來,您就可以通過 10 行代碼查看任何 ZIP 碼所代表的位置的天氣信息,如清單 17 所示:
##### 清單 17\. 添加一個 `weather` 方法
```
String.metaClass.weather={->
if(!delegate.isInteger()){
return "The weather() method only works with zip codes like '90201'"
}
def addr = "http://weather.yahooapis.com/forecastrss?p=${delegate}"
def rss = new XmlSlurper().parse(addr)
def results = rss.channel.item.title
results << "\n" + rss.channel.item.condition.@text
results << "\nTemp: " + rss.channel.item.condition.@temp
}
println "80020".weather()
//output
Conditions for Broomfield, CO at 1:57 pm MDT
Mostly Cloudy
Temp: 72
```
可以看到,元編程提供了極好的靈活性。您可以使用本文介紹的任何(或所有)技巧來向任意數量的類添加方法。
* * *
## 結束語
要求世界因語言的限制而改變顯然不切實際。使用軟件建模真實世界意味著需要有足夠靈活的工具來處理所有極端情況。幸運的是,使用 Groovy 提供的閉包、`ExpandoMetaClasses` 和類別,您就擁有了一組出色的工具,可以根據自己的意愿添加行為。
在下一期文章中,我將重新審視 Groovy 在單元測試方面的強大功能。使用 Groovy 編寫測試會帶來實際的效益,不管是 `GroovyTestCase` 或包含注釋的 JUnit 4.x 測試用例。您將看到 GMock 的實際作用 — 一種使用 Groovy 編寫的模擬框架。到那時,希望您能夠發現 Groovy 的許多實際應用。
* * *
## 下載
| 描述 | 名字 | 大小 |
| --- | --- | --- |
| 本文示例的源代碼 | [j-pg06239.zip](http://www.ibm.com/developerworks/apps/download/index.jsp?contentid=413546&filename=j-pg06239.zip&method=http&locale=zh_CN) | 7KB |
- 實戰 Groovy
- 實戰 Groovy: SwingBuilder 和 Twitter API,第 2 部分
- 實戰 Groovy: SwingBuilder 和 Twitter API,第 1 部分
- 實戰 Groovy: @Delegate 注釋
- 實戰 Groovy: 使用閉包、ExpandoMetaClass 和類別進行元編程
- 實戰 Groovy: 構建和解析 XML
- 實戰 Groovy: for each 剖析
- 實戰 Groovy: Groovy:Java 程序員的 DSL
- 實戰 Groovy: 關于 MOP 和迷你語言
- 實戰 Groovy: 用 curry 過的閉包進行函數式編程
- 實戰 Groovy: Groovy 的騰飛
- 實戰 Groovy: 在 Java 應用程序中加一些 Groovy 進來
- 實戰 Groovy: 用 Groovy 生成器作標記
- 實戰 Groovy: 用 Groovy 打造服務器端
- 實戰 Groovy: 使用 Groovy 模板進行 MVC 編程
- 實戰 Groovy: 用 Groovy 進行 JDBC 編程
- 實戰 Groovy: 用 Groovy 進行 Ant 腳本編程
- 實戰 Groovy: 用 Groovy 更迅速地對 Java 代碼進行單元測試
- alt.lang.jre: 感受 Groovy