語意

本章涵蓋 Groovy 程式語言的語意。

1. 陳述式

1.1. 變數定義

變數可以使用其類型 (例如 String) 定義,或使用關鍵字 def (或 var) 後面接上變數名稱來定義

String x
def y
var z

defvar 作為類型佔位符,也就是類型名稱的替代,當您不想提供明確類型時。可能是您在編譯時不關心類型,或依賴類型推論 (使用 Groovy 的靜態特性)。變數定義必須要有類型或佔位符。如果省略,類型名稱將被視為參考現有的變數 (假設先前已宣告)。對於腳本,未宣告的變數假設來自 Script 繫結。在其他情況下,您會收到遺失屬性 (動態 Groovy) 或編譯時錯誤 (靜態 Groovy)。如果您將 defvar 視為 Object 的別名,您會立即理解。

變數定義可以提供初始值,這種情況就像同時具有宣告和指派 (我們接下來會介紹)。

變數定義類型可以使用泛型來精煉,例如在 List<String> names 中。若要深入了解泛型支援,請閱讀 泛型區段

1.2. 變數指定

您可以指定值給變數以供稍後使用。請嘗試下列操作

x = 1
println x

x = new java.util.Date()
println x

x = -3.1499392
println x

x = false
println x

x = "Hi"
println x

1.2.1. 多重指定

Groovy 支援多重指定,亦即一次可以指定多個變數,例如

def (a, b, c) = [10, 20, 'foo']
assert a == 10 && b == 20 && c == 'foo'

如果您願意,可以在宣告時提供類型

def (int i, String j) = [10, 'foo']
assert i == 10 && j == 'foo'

除了在宣告變數時使用外,它也適用於現有的變數

def nums = [1, 3, 5]
def a, b, c
(a, b, c) = nums
assert a == 1 && b == 3 && c == 5

此語法適用於陣列和清單,以及傳回這些陣列或清單的方法

def (_, month, year) = "18th June 2009".split()
assert "In $month of $year" == 'In June of 2009'

1.2.2. 溢位和下溢

如果左側有太多變數,多餘的變數會填入 null

def (a, b, c) = [1, 2]
assert a == 1 && b == 2 && c == null

如果右側有太多變數,多餘的變數會被忽略

def (a, b) = [1, 2, 3]
assert a == 1 && b == 2

1.2.3. 使用多重指定進行物件解構

在描述 Groovy 算子的區段中,已涵蓋 下標算子 的情況,說明如何覆寫 getAt()/putAt() 方法。

使用此技術,我們可以結合多重指定和下標算子方法來實作物件解構

考慮以下不可變的 Coordinates 類別,其中包含一對經度和緯度雙精度,並注意我們對 getAt() 方法的實作

@Immutable
class Coordinates {
    double latitude
    double longitude

    double getAt(int idx) {
        if (idx == 0) latitude
        else if (idx == 1) longitude
        else throw new Exception("Wrong coordinate index, use 0 or 1")
    }
}

現在讓我們實例化此類別並解構其經度和緯度

def coordinates = new Coordinates(latitude: 43.23, longitude: 3.67) (1)

def (la, lo) = coordinates                                          (2)

assert la == 43.23                                                  (3)
assert lo == 3.67
1 我們建立 Coordinates 類別的實例
2 然後,我們使用多重指定來取得個別的經度和緯度值
3 最後,我們可以確認其值。

1.3. 控制結構

1.3.1. 條件結構

if / else

Groovy 支援 Java 中常見的 if - else 語法

def x = false
def y = false

if ( !x ) {
    x = true
}

assert x == true

if ( x ) {
    x = false
} else {
    y = true
}

assert x == y

Groovy 也支援正常的 Java「巢狀」if then else if 語法

if ( ... ) {
    ...
} else if (...) {
    ...
} else {
    ...
}
switch / case

Groovy 中的 switch 語法與 Java 程式碼向下相容;因此,你可以使用相同程式碼處理多個符合條件的案例。

不過,Groovy switch 語法有一個不同點,在於它可以處理任何類型的 switch 值,並執行不同類型的比對。

def x = 1.23
def result = ""

switch (x) {
    case "foo":
        result = "found foo"
        // lets fall through

    case "bar":
        result += "bar"

    case [4, 5, 6, 'inList']:
        result = "list"
        break

    case 12..30:
        result = "range"
        break

    case Integer:
        result = "integer"
        break

    case Number:
        result = "number"
        break

    case ~/fo*/: // toString() representation of x matches the pattern?
        result = "foo regex"
        break

    case { it < 0 }: // or { x < 0 }
        result = "negative"
        break

    default:
        result = "default"
}

assert result == "number"

Switch 支援下列類型的比較

  • 類別案例值:如果 switch 值是類別的執行個體,則符合條件

  • 正規表示式案例值:如果 switch 值的 `toString()` 表示與正規表示式相符,則符合條件

  • 集合案例值:如果 switch 值包含在集合中,則符合條件。這也包括範圍(因為它們是清單)

  • 閉包案例值:如果呼叫閉包傳回的結果根據 Groovy 真實性 為真,則符合條件

  • 如果沒有使用上述任何條件,則當案例值等於 switch 值時,案例值符合條件

使用閉包案例值時,預設的 `it` 參數實際上是 switch 值(在我們的範例中,變數 `x`)。

Groovy 也支援 switch 表達式,如下列範例所示

def partner = switch(person) {
    case 'Romeo'  -> 'Juliet'
    case 'Adam'   -> 'Eve'
    case 'Antony' -> 'Cleopatra'
    case 'Bonnie' -> 'Clyde'
}

1.3.2. 迴圈結構

傳統 for 迴圈

Groovy 支援標準 Java / C for 迴圈

String message = ''
for (int i = 0; i < 5; i++) {
    message += 'Hi '
}
assert message == 'Hi Hi Hi Hi Hi '
增強的傳統 Java 風格 for 迴圈

現在支援使用逗號分隔表達式的 Java 傳統 for 迴圈的更精細形式。範例

def facts = []
def count = 5
for (int fact = 1, i = 1; i <= count; i++, fact *= i) {
    facts << fact
}
assert facts == [1, 2, 6, 24, 120]
多重指定與 for 迴圈結合使用

Groovy 自 Groovy 1.6 起支援多重指定陳述式

// multi-assignment with types
def (String x, int y) = ['foo', 42]
assert "$x $y" == 'foo 42'

現在它們可以在 for 迴圈中出現

// multi-assignment goes loopy
def baNums = []
for (def (String u, int v) = ['bar', 42]; v < 45; u++, v++) {
    baNums << "$u $v"
}
assert baNums == ['bar 42', 'bas 43', 'bat 44']
for in 迴圈

Groovy 中的 for 迴圈簡單多了,而且適用於任何類型的陣列、集合、Map 等。

// iterate over a range
def x = 0
for ( i in 0..9 ) {
    x += i
}
assert x == 45

// iterate over a list
x = 0
for ( i in [0, 1, 2, 3, 4] ) {
    x += i
}
assert x == 10

// iterate over an array
def array = (0..4).toArray()
x = 0
for ( i in array ) {
    x += i
}
assert x == 10

// iterate over a map
def map = ['abc':1, 'def':2, 'xyz':3]
x = 0
for ( e in map ) {
    x += e.value
}
assert x == 6

// iterate over values in a map
x = 0
for ( v in map.values() ) {
    x += v
}
assert x == 6

// iterate over the characters in a string
def text = "abc"
def list = []
for (c in text) {
    list.add(c)
}
assert list == ["a", "b", "c"]
Groovy 也支援使用冒號的 Java 冒號變形:for (char c : text) {}
while 迴圈

Groovy 支援與 Java 相同的 while {…​} 迴圈

def x = 0
def y = 5

while ( y-- > 0 ) {
    x++
}

assert x == 5
do/while 迴圈

現在支援 Java 的 do/while 迴圈類別。範例

// classic Java-style do..while loop
def count = 5
def fact = 1
do {
    fact *= count--
} while(count > 1)
assert fact == 120

1.3.3. 例外處理

例外處理與 Java 相同。

1.3.4. try / catch / finally

您可以指定完整的 try-catch-finallytry-catchtry-finally 區塊集。

每個區塊主體周圍都需要大括號。
try {
    'moo'.toLong()   // this will generate an exception
    assert false     // asserting that this point should never be reached
} catch ( e ) {
    assert e in NumberFormatException
}

我們可以在與 'try' 區塊匹配的 'finally' 區塊中放置程式碼,因此無論 'try' 區塊中的程式碼是否擲回例外,finally 區塊中的程式碼都將始終執行

def z
try {
    def i = 7, j = 0
    try {
        def k = i / j
        assert false        //never reached due to Exception in previous line
    } finally {
        z = 'reached here'  //always executed even if Exception thrown
    }
} catch ( e ) {
    assert e in ArithmeticException
    assert z == 'reached here'
}

1.3.5. 多重 catch

使用多重 catch 區塊(自 Groovy 2.0 起),我們可以定義多個例外,由同一個 catch 區塊 catch 並處理

try {
    /* ... */
} catch ( IOException | NullPointerException e ) {
    /* one block to handle 2 exceptions */
}

1.3.6. ARM Try with resources

Groovy 通常提供比 Java 7 的 try-with-resources 陳述式更好的自動資源管理 (ARM) 替代方案。此語法現在支援要移轉到 Groovy 並仍想使用舊樣式的 Java 程式設計人員

class FromResource extends ByteArrayInputStream {
    @Override
    void close() throws IOException {
        super.close()
        println "FromResource closing"
    }

    FromResource(String input) {
        super(input.toLowerCase().bytes)
    }
}

class ToResource extends ByteArrayOutputStream {
    @Override
    void close() throws IOException {
        super.close()
        println "ToResource closing"
    }
}

def wrestle(s) {
    try (
            FromResource from = new FromResource(s)
            ToResource to = new ToResource()
    ) {
        to << from
        return to.toString()
    }
}

def wrestle2(s) {
    FromResource from = new FromResource(s)
    try (from; ToResource to = new ToResource()) { // Enhanced try-with-resources in Java 9+
        to << from
        return to.toString()
    }
}

assert wrestle("ARM was here!").contains('arm')
assert wrestle2("ARM was here!").contains('arm')

產生下列輸出

ToResource closing
FromResource closing
ToResource closing
FromResource closing

1.4. 強力斷言

與 Groovy 共用 assert 關鍵字的 Java 不同,Groovy 中的後者行為非常不同。首先,Groovy 中的斷言總是執行,與 JVM 的 -ea 旗標無關。這讓它成為單元測試的首選。'強大斷言' 的概念與 Groovy assert 的行為直接相關。

強大斷言分解成 3 個部分

assert [left expression] == [right expression] : (optional message)

斷言的結果與您在 Java 中得到的結果非常不同。如果斷言為真,則不會發生任何事。如果斷言為假,則會提供正在斷言的表達式中每個子表達式的值的可視化表示。例如

assert 1+1 == 3

將產生

Caught: Assertion failed:

assert 1+1 == 3
        |  |
        2  false

當表達式更複雜時,強大斷言會變得非常有趣,如下一個範例

def x = 2
def y = 7
def z = 5
def calc = { a,b -> a*b+1 }
assert calc(x,y) == [x,z].sum()

將印出每個子表達式的值

assert calc(x,y) == [x,z].sum()
       |    | |  |   | |  |
       15   2 7  |   2 5  7
                 false

如果您不想要如上所示的漂亮列印錯誤訊息,您可以透過變更斷言的選用訊息部分來改用自訂錯誤訊息,如下面的範例

def x = 2
def y = 7
def z = 5
def calc = { a,b -> a*b+1 }
assert calc(x,y) == z*z : 'Incorrect computation result'

將印出下列錯誤訊息

Incorrect computation result. Expression: (calc.call(x, y) == (z * z)). Values: z = 5, z = 5

1.5. 標籤陳述式

任何陳述式都可以與標籤關聯。標籤不會影響程式碼的語意,可用於讓程式碼更容易閱讀,如下面的範例

given:
    def x = 1
    def y = 2
when:
    def z = x+y
then:
    assert z == 3

儘管不會變更標籤陳述式的語意,但可以在 break 指令中使用標籤作為跳躍目標,如下一個範例。然而,即使允許這麼做,這種編碼樣式通常被認為是不良做法

for (int i=0;i<10;i++) {
    for (int j=0;j<i;j++) {
        println "j=$j"
        if (j == 5) {
            break exit
        }
    }
    exit: println "i=$i"
}

重要的是要了解,預設情況下標籤不會影響程式碼的語意,但它們屬於抽象語法樹 (AST),因此 AST 轉換可以使用該資訊對程式碼執行轉換,因此導致不同的語意。這特別是 Spock Framework 為簡化測試所做的工作。

2. 表達式

表達式是 Groovy 程式中用來參照現有值和執行程式碼以建立新值的建構模組。

Groovy 支援許多與 Java 相同類型的表達式,包括

表 1. 類似 Java 的表達式

範例表達式

說明

foo

變數、欄位、參數的名稱…​

thissuperit

特殊名稱

true10"bar"

文字

String.class

類別文字

( expression )

括號表達式

foo++~bar

一元 運算子 表達式

foo + barbar * baz

二元 運算子 表達式

foo ? bar : baz

三元 運算子 表達式

(Integer x, Integer y) → x + y

Lambda 表達式

assert 'bar' == switch('foo') {
  case 'foo' -> 'bar'
}

switch 表達式

Groovy 也有自己的一些特殊表達式

表 2. 特殊表達式

範例表達式

說明

字串

簡寫類別文字(在不含糊的情況下)

{ x, y → x + y }

閉包表達式

[1, 3, 5]

文字清單表達式

[a:2, b:4, c:6]

文字映射表達式

Groovy 也擴充了 Java 中用於成員存取的標準點號表示法。Groovy 提供了特殊支援,可透過指定感興趣資料的階層中路徑來存取階層化資料結構。這些 Groovy 路徑 表達式稱為 GPath 表達式。

2.1. GPath 表達式

GPath 是一種整合到 Groovy 中的路徑表達式語言,可識別巢狀結構化資料的各個部分。在這個意義上,它具有與 XPath 對 XML 所具有的類似目標和範圍。GPath 通常用於處理 XML 的情況中,但它實際上適用於任何物件圖形。XPath 使用類似檔案系統的路徑表示法,一個樹狀階層,其各部分由斜線 / 分隔,而 GPath 使用點物件表示法 來執行物件導覽。

舉例來說,您可以指定感興趣物件或元素的路徑

  • a.b.c → 對於 XML,會產生 a 內的 b 內的所有 c 元素

  • a.b.c → 對於 POJO,會產生 a 的所有 b 屬性的 c 屬性(有點類似 JavaBeans 中的 a.getB().getC()

在這兩種情況下,GPath 表達式都可以視為對物件圖形的查詢。對於 POJO,物件圖形通常是由程式透過物件實例化和組合來建立;對於 XML 處理,物件圖形是 剖析 XML 文字的結果,通常使用 XmlParser 或 XmlSlurper 等類別。請參閱 處理 XML,以取得在 Groovy 中使用 XML 的更深入詳細資料。

在查詢由 XmlParser 或 XmlSlurper 產生的物件圖形時,GPath 表達式可以使用 @ 符號來參照元素上定義的屬性

  • a["@href"] → 類似映射的符號:所有 a 元素的 href 屬性

  • a.'@href' → 屬性符號:表達此屬性的另一種方式

  • a.@href → 直接符號:表達此屬性的另一種方式

2.1.1. 物件導覽

讓我們來看一個在簡單物件圖形上使用 GPath 表達式的範例,這個圖形是使用 Java 反射取得的。假設你處在一個類別的非靜態方法中,這個類別有另一個名為 aMethodFoo 的方法

void aMethodFoo() { println "This is aMethodFoo." } (0)

下列 GPath 表達式會取得該方法的名稱

assert ['aMethodFoo'] == this.class.methods.name.grep(~/.*Foo/)

更精確地說,上述 GPath 表達式會產生一個字串清單,每個字串都是 this 上現有方法的名稱,且名稱以 Foo 結尾。

現在,假設該類別中還定義了以下方法

void aMethodBar() { println "This is aMethodBar." } (1)
void anotherFooMethod() { println "This is anotherFooMethod." } (2)
void aSecondMethodBar() { println "This is aSecondMethodBar." } (3)

那麼下列 GPath 表達式會取得 (1)(3) 的名稱,但不會取得 (2)(0)

assert ['aMethodBar', 'aSecondMethodBar'] as Set == this.class.methods.name.grep(~/.*Bar/) as Set

2.1.2. 表達式解構

我們可以分解表達式 this.class.methods.name.grep(~/.*Bar/),以了解 GPath 的評估方式

this.class

屬性存取器,等於 Java 中的 this.getClass(),會產生一個 Class 物件。

this.class.methods

屬性存取器,等於 this.getClass().getMethods(),會產生一個 Method 物件陣列。

this.class.methods.name

在陣列的每個元素上套用屬性存取器,並產生結果清單。

this.class.methods.name.grep(…​)

在 this.class.methods.name 所產生的清單的每個元素上呼叫方法 grep,並產生結果清單。

像 this.class.methods 這樣的子表達式會產生陣列,因為這是在 Java 中呼叫 this.getClass().getMethods() 所會產生的結果。GPath 表達式沒有慣例表示 s 表示清單或類似的東西。

GPath 表達式的一個強大功能是,對集合的屬性存取會轉換成「對集合中每個元素的屬性存取」,而結果會收集到集合中。因此,表達式 this.class.methods.name 可以用下列 Java 方式表示

List<String> methodNames = new ArrayList<String>();
for (Method method : this.getClass().getMethods()) {
   methodNames.add(method.getName());
}
return methodNames;

陣列存取符號也可以用在 GPath 表達式中,其中存在集合

assert 'aSecondMethodBar' == this.class.methods.name.grep(~/.*Bar/).sort()[1]
在 GPath 表達式中,陣列存取是以 0 為基礎

2.1.3. 用於 XML 導覽的 GPath

以下是 XML 文件和各種形式 GPath 表達式的範例

def xmlText = """
              | <root>
              |   <level>
              |      <sublevel id='1'>
              |        <keyVal>
              |          <key>mykey</key>
              |          <value>value 123</value>
              |        </keyVal>
              |      </sublevel>
              |      <sublevel id='2'>
              |        <keyVal>
              |          <key>anotherKey</key>
              |          <value>42</value>
              |        </keyVal>
              |        <keyVal>
              |          <key>mykey</key>
              |          <value>fizzbuzz</value>
              |        </keyVal>
              |      </sublevel>
              |   </level>
              | </root>
              """
def root = new XmlSlurper().parseText(xmlText.stripMargin())
assert root.level.size() == 1 (1)
assert root.level.sublevel.size() == 2 (2)
assert root.level.sublevel.findAll { it.@id == 1 }.size() == 1 (3)
assert root.level.sublevel[1].keyVal[0].key.text() == 'anotherKey' (4)
1 在 root 下有一個 level 節點
2 在 root/level 下有兩個 sublevel 節點
3 有一個 sublevel 元素,其 id 屬性值為 1
4 在 root/level 下的第二個 sublevel 元素的第一個 keyVal 元素的 key 元素的文字值為 'anotherKey'

有關用於 XML 的 GPath 表達式的更多詳細資訊,請參閱 XML 使用者指南

3. 提升和強制轉換

3.1. 數字提升

數字提升的規則已在 數學運算章節中說明。

3.2. 閉包轉換為類型

3.2.1. 將閉包指定給 SAM 類型

SAM 類型是定義單一抽象方法的類型。這包括

函數式介面
interface Predicate<T> {
    boolean accept(T obj)
}
具有單一抽象方法的抽象類別
abstract class Greeter {
    abstract String getName()
    void greet() {
        println "Hello, $name"
    }
}

任何閉包都可以使用 as 運算子轉換為 SAM 類型

Predicate filter = { it.contains 'G' } as Predicate
assert filter.accept('Groovy') == true

Greeter greeter = { 'Groovy' } as Greeter
greeter.greet()

不過,自 Groovy 2.2.0 起,as Type 表達式為選用。您可以省略它,並簡單地撰寫

Predicate filter = { it.contains 'G' }
assert filter.accept('Groovy') == true

Greeter greeter = { 'Groovy' }
greeter.greet()

這表示您也可以使用方法指標,如下列範例所示

boolean doFilter(String s) { s.contains('G') }

Predicate filter = this.&doFilter
assert filter.accept('Groovy') == true

Greeter greeter = GroovySystem.&getVersion
greeter.greet()

3.2.2. 使用閉包呼叫接受 SAM 類型的函數

封閉轉換為 SAM 類型強制轉換的第二個,也可能是更重要的使用案例是呼叫接受 SAM 類型的函式。想像一下下列函式

public <T> List<T> filter(List<T> source, Predicate<T> predicate) {
    source.findAll { predicate.accept(it) }
}

然後,您可以使用封閉呼叫它,而不需要建立介面的明確實作

assert filter(['Java','Groovy'], { it.contains 'G'} as Predicate) == ['Groovy']

但自 Groovy 2.2.0 以後,您也可以省略明確強制轉換,並呼叫函式,就像它使用封閉一樣

assert filter(['Java','Groovy']) { it.contains 'G'} == ['Groovy']

如您所見,這有讓您使用封閉語法進行函式呼叫的優點,也就是說將封閉放在括號外,改善您程式碼的可讀性。

3.2.3. 封閉轉換為任意類型強制轉換

除了 SAM 類型之外,封閉可以強制轉換為任何類型,特別是介面。我們來定義下列介面

interface FooBar {
    int foo()
    void bar()
}

您可以使用 as 關鍵字將封閉強制轉換為介面

def impl = { println 'ok'; 123 } as FooBar

這會產生一個類別,其所有函式都使用封閉實作

assert impl.foo() == 123
impl.bar()

但也可以將封閉強制轉換為任何類別。例如,我們可以將我們定義的 interface 取代為 class,而不用變更斷言

class FooBar {
    int foo() { 1 }
    void bar() { println 'bar' }
}

def impl = { println 'ok'; 123 } as FooBar

assert impl.foo() == 123
impl.bar()

3.3. Map 轉換為類型強制轉換

通常,使用單一封閉來實作具有多個函式的介面或類別並非可行之道。作為替代方案,Groovy 允許您將 map 強制轉換為介面或類別。在這種情況下,map 的金鑰會被解釋為函式名稱,而值則為函式實作。下列範例說明將 map 強制轉換為 Iterator

def map
map = [
  i: 10,
  hasNext: { map.i > 0 },
  next: { map.i-- },
]
def iter = map as Iterator

當然,這是一個相當牽強的範例,但說明了這個概念。您只需要實作實際呼叫的那些函式,但如果呼叫了 map 中不存在的函式,則會擲回 MissingMethodExceptionUnsupportedOperationException,視傳遞給呼叫的引數而定,如下列範例所示

interface X {
    void f()
    void g(int n)
    void h(String s, int n)
}

x = [ f: {println "f called"} ] as X
x.f() // method exists
x.g() // MissingMethodException here
x.g(5) // UnsupportedOperationException here

例外狀況的類型取決於呼叫本身

  • 如果呼叫的引數與介面/類別的引數不符,則為 MissingMethodException

  • 如果呼叫的引數與介面/類別的其中一個重載函式相符,則為 UnsupportedOperationException

3.4. 字串轉換為列舉強制轉換

Groovy 允許透明的 字串 (或 G 字串) 轉換為列舉值。假設您定義下列列舉

enum State {
    up,
    down
}

然後,您可以將字串指定給列舉,而無需使用明確的 as 轉換

State st = 'up'
assert st == State.up

也可以使用 G 字串 作為值

def val = "up"
State st = "${val}"
assert st == State.up

但是,這會擲出執行時期錯誤 (IllegalArgumentException)

State st = 'not an enum value'

請注意,也可以在 switch 陳述式中使用隱式轉換

State switchState(State st) {
    switch (st) {
        case 'up':
            return State.down // explicit constant
        case 'down':
            return 'up' // implicit coercion for return types
    }
}

特別是,請注意 case 如何使用字串常數。但是,如果您呼叫使用列舉和 字串 參數的方法,則仍然必須使用明確的 as 轉換

assert switchState('up' as State) == State.down
assert switchState(State.down) == State.up

3.5. 自訂類型轉換

類別可以透過實作 asType 方法來定義自訂轉換策略。自訂轉換是使用 as 算子呼叫,而且從不隱含。舉例來說,假設您定義了兩個類別,PolarCartesian,如下例所示

class Polar {
    double r
    double phi
}
class Cartesian {
   double x
   double y
}

而且您想要將極座標轉換為直角座標。執行此操作的方法之一是在 Polar 類別中定義 asType 方法

def asType(Class target) {
    if (Cartesian==target) {
        return new Cartesian(x: r*cos(phi), y: r*sin(phi))
    }
}

這允許您使用 as 轉換算子

def sigma = 1E-16
def polar = new Polar(r:1.0,phi:PI/2)
def cartesian = polar as Cartesian
assert abs(cartesian.x-sigma) < sigma

將所有內容組合在一起,Polar 類別如下所示

class Polar {
    double r
    double phi
    def asType(Class target) {
        if (Cartesian==target) {
            return new Cartesian(x: r*cos(phi), y: r*sin(phi))
        }
    }
}

但是,也可以在 Polar 類別外部定義 asType,如果您想要為「封閉」類別或您不擁有其原始碼的類別定義自訂轉換策略,這會很實用,例如使用元類別

Polar.metaClass.asType = { Class target ->
    if (Cartesian==target) {
        return new Cartesian(x: r*cos(phi), y: r*sin(phi))
    }
}

3.6. 類別文字與變數和 as 算子

只有在您有類別的靜態參考時,才能使用 as 關鍵字,如下列程式碼所示

interface Greeter {
    void greet()
}
def greeter = { println 'Hello, Groovy!' } as Greeter // Greeter is known statically
greeter.greet()

但是,如果您透過反射取得類別,例如呼叫 Class.forName,該怎麼辦?

Class clazz = Class.forName('Greeter')

嘗試使用 as 關鍵字和類別參考會失敗

greeter = { println 'Hello, Groovy!' } as clazz
// throws:
// unable to resolve class clazz
// @ line 9, column 40.
//   greeter = { println 'Hello, Groovy!' } as clazz

它會失敗,因為 as 關鍵字只適用於類別文字。您需要呼叫 asType 方法

greeter = { println 'Hello, Groovy!' }.asType(clazz)
greeter.greet()

4. 選擇性

4.1. 選擇性括號

方法呼叫中,如果至少有一個參數且沒有歧義,則可以省略括號

println 'Hello World'
def maximum = Math.max 5, 10

對於沒有參數或歧義的方法呼叫,括號是必需的

println()
println(Math.max(5, 10))

4.2. 可選分號

在 Groovy 中,如果一行只包含一個陳述式,則可以省略行尾的分號。

這表示

assert true;

可以更慣用語法寫成

assert true

一行中有多個陳述式需要用分號分隔

boolean a = true; assert a

4.3. 可選回傳關鍵字

在 Groovy 中,方法或閉包主體中評估的最後一個表達式會傳回。這表示 return 關鍵字是可選的。

int add(int a, int b) {
    return a+b
}
assert add(1, 2) == 3

可以縮短為

int add(int a, int b) {
    a+b
}
assert add(1, 2) == 3

4.4. 可選 public 關鍵字

預設情況下,Groovy 類別和方法為 public。因此,這個類別

public class Server {
    public String toString() { "a server" }
}

等同於這個類別

class Server {
    String toString() { "a server" }
}

5. Groovy 真理

Groovy 透過套用以下規則來決定表達式為真或假。

5.1. 布林表達式

如果對應的布林值為 true,則為真。

assert true
assert !false

5.2. 集合和陣列

非空的集合和陣列為真。

assert [1, 2, 3]
assert ![]

5.3. 比對器

如果比對器至少有一個比對,則為真。

assert ('a' =~ /a/)
assert !('a' =~ /b/)

5.4. 迭代器和列舉

具有更多元素的迭代器和列舉會強制轉換為真。

assert [0].iterator()
assert ![].iterator()
Vector v = [0] as Vector
Enumeration enumeration = v.elements()
assert enumeration
enumeration.nextElement()
assert !enumeration

5.5. 地圖

非空的映射會評估為真。

assert ['one' : 1]
assert ![:]

5.6. 字串

非空的字串、G 字串和字元序列會強制轉換為真。

assert 'a'
assert !''
def nonEmpty = 'a'
assert "$nonEmpty"
def empty = ''
assert !"$empty"

5.7. 數字

非零數字為真。

assert 1
assert 3.5
assert !0

5.8. 物件參考

非空物件參考會強制轉換為 true。

assert new Object()
assert !null

5.9. 使用 asBoolean() 方法自訂真假值

若要自訂 groovy 是否將物件評估為 truefalse,請實作 asBoolean() 方法

class Color {
    String name

    boolean asBoolean(){
        name == 'green' ? true : false 
    }
}

Groovy 會呼叫此方法將物件強制轉換為布林值,例如:

assert new Color(name: 'green')
assert !new Color(name: 'red')

6. 編寫類型

6.1. 選擇性編寫類型

選擇性編寫類型是指即使您未在變數上放置明確類型,程式仍可運作。Groovy 是一種動態語言,自然會實作這項功能,例如,當您宣告變數時

String aString = 'foo'                      (1)
assert aString.toUpperCase()                (2)
1 foo 使用明確類型 String 宣告
2 我們可以在 String 上呼叫 toUpperCase 方法

Groovy 會讓您改為撰寫此內容

def aString = 'foo'                         (1)
assert aString.toUpperCase()                (2)
1 foo 使用 def 宣告
2 我們仍可呼叫 toUpperCase 方法,因為 aString 的類型會在執行階段解析

因此,您在此處使用明確類型並無關係。當您將此功能與 靜態類型檢查 結合使用時,特別有趣,因為類型檢查器會執行類型推論。

同樣地,Groovy 並未強制要求在方法中宣告參數的類型

String concat(String a, String b) {
    a+b
}
assert concat('foo','bar') == 'foobar'

可以使用 def 作為回傳類型和參數類型改寫,以利用鴨子編寫類型,如本範例所示

def concat(def a, def b) {                              (1)
    a+b
}
assert concat('foo','bar') == 'foobar'                  (2)
assert concat(1,2) == 3                                 (3)
1 回傳類型和參數類型都使用 def
2 這使得可以使用 String 方法
3 但也可以使用 int,因為已定義 plus 方法
建議在此處使用 def 關鍵字來描述應可適用於任何類型的意圖,但技術上,我們可以使用 Object,結果會相同:在 Groovy 中,def 與使用 Object 嚴格等效。

最後,可以完全從回傳類型和描述符中移除類型。但如果您要從回傳類型中移除,則需要為方法新增明確修改子,以便編譯器可以區分方法宣告和方法呼叫,如本範例所示

private concat(a,b) {                                   (1)
    a+b
}
assert concat('foo','bar') == 'foobar'                  (2)
assert concat(1,2) == 3                                 (3)
1 如果我們要省略回傳類型,則必須設定明確修改子。
2 仍可以使用 String 方法
3 也可以使用 int
在公開 API 中的方法參數或方法傳回類型中省略類型通常被視為一種不良做法。雖然在局部變數中使用 def 並非真正問題,因為變數的能見度僅限於方法本身,而設定在方法參數上時,def 將會在方法簽章中轉換為 Object,使用戶難以得知參數的預期類型。這表示您應將此限制在明確依賴鴨子型別的情況。

6.2. 靜態類型檢查

預設情況下,Groovy 會在編譯時執行最少類型檢查。由於它主要是動態語言,因此靜態編譯器通常會執行的多數檢查無法在編譯時執行。透過執行時期元程式設計新增的方法可能會變更類別或物件的執行時期行為。讓我們在以下範例中說明原因

class Person {                                                          (1)
    String firstName
    String lastName
}
def p = new Person(firstName: 'Raymond', lastName: 'Devos')             (2)
assert p.formattedName == 'Raymond Devos'                               (3)
1 Person 類別僅定義兩個屬性,firstNamelastName
2 我們可以建立 Person 的執行個體
3 並呼叫名為 formattedName 的方法

在動態語言中,類似以上範例的程式碼通常不會擲回任何錯誤。這是怎麼回事?在 Java 中,這通常會在編譯時失敗。然而,在 Groovy 中,它不會在編譯時失敗,而且如果編寫正確,也不會在執行時期失敗。事實上,要讓這在執行時期運作,一種可能性是依賴執行時期元程式設計。因此,在 Person 類別宣告後新增這行就足夠了

Person.metaClass.getFormattedName = { "$delegate.firstName $delegate.lastName" }

這表示在 Groovy 中,通常無法對物件的類型做出任何假設,超出其宣告類型,而且即使您知道,也無法在編譯時確定會呼叫哪個方法,或會擷取哪個屬性。這有很大的興趣,從撰寫 DSL 到測試,這在手冊的其他部分中會討論。

然而,如果您的程式不依賴動態功能,而且您來自靜態世界(特別是來自 Java 思維),那麼在編譯時無法捕捉到此類「錯誤」可能會令人驚訝。正如我們在先前的範例中所見,編譯器無法確定這是否為錯誤。要讓它知道這是錯誤,您必須明確指示編譯器您正在切換到類型檢查模式。這可以透過使用 @groovy.transform.TypeChecked 註解類別或方法來完成。

當類型檢查啟動時,編譯器會執行更多工作

  • 類型推論已啟用,表示即使您在區域變數上使用 def,類型檢查器仍能從指定值推論變數的類型

  • 方法呼叫會在編譯時解析,表示如果類別上未宣告方法,編譯器將擲回錯誤

  • 一般來說,您習慣在靜態語言中找到的所有編譯時錯誤都會出現:找不到方法、找不到屬性、方法呼叫類型不相容、數字精確度錯誤,…​

在本節中,我們將說明類型檢查器在各種情況下的行為,並說明在程式碼上使用 @TypeChecked 的限制。

6.2.1. @TypeChecked 註解

在編譯時啟用類型檢查

groovy.transform.TypeChecked 註解可啟用類型檢查。它可以放在類別上

@groovy.transform.TypeChecked
class Calculator {
    int sum(int x, int y) { x+y }
}

或放在方法上

class Calculator {
    @groovy.transform.TypeChecked
    int sum(int x, int y) { x+y }
}

在第一個情況下,註解類別的所有方法、屬性、欄位、內部類別,…​ 都會進行類型檢查,而在第二個情況下,只有方法和它包含的潛在閉包或匿名內部類別會進行類型檢查。

略過部分

類型檢查的範圍可以受到限制。例如,如果類別已進行類型檢查,您可以指示類型檢查器略過方法,方法是使用 @TypeChecked(TypeCheckingMode.SKIP) 對其進行註解

import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode

@TypeChecked                                        (1)
class GreetingService {
    String greeting() {                             (2)
        doGreet()
    }

    @TypeChecked(TypeCheckingMode.SKIP)             (3)
    private String doGreet() {
        def b = new SentenceBuilder()
        b.Hello.my.name.is.John                     (4)
        b
    }
}
def s = new GreetingService()
assert s.greeting() == 'Hello my name is John'
1 GreetingService 類別標示為已進行類型檢查
2 因此 greeting 方法會自動進行類型檢查
3 doGreet 標示為 SKIP
4 類型檢查器不會抱怨這裡缺少屬性

在先前的範例中,SentenceBuilder 依賴動態程式碼。沒有實際的 Hello 方法或屬性,因此類型檢查器通常會抱怨,而且編譯會失敗。由於使用建構函式的程式碼標示為 TypeCheckingMode.SKIP,因此會為此方法略過類型檢查,因此程式碼會編譯,即使類別的其餘部分已進行類型檢查。

以下各節說明 Groovy 中類型檢查的語意。

6.2.2. 類型檢查指定值

類型為 A 的物件 o 可以指派給類型為 T 的變數,當且僅當

  • T 等於 A

    Date now = new Date()
  • TStringbooleanBooleanClass 之一

    String s = new Date() // implicit call to toString
    Boolean boxed = 'some string'       // Groovy truth
    boolean prim = 'some string'        // Groovy truth
    Class clazz = 'java.lang.String'    // class coercion
  • o 為 null 且 T 不是原始類型

    String s = null         // passes
    int i = null            // fails
  • T 是陣列且 A 是陣列,且 A 的元件類型可以指派給 T 的元件類型

    int[] i = new int[4]        // passes
    int[] i = new String[4]     // fails
  • T 是陣列且 A 是集合或串流,且 A 的元件類型可以指派給 T 的元件類型

    int[] i = [1,2,3]               // passes
    int[] i = [1,2, new Date()]     // fails
    Set set = [1,2,3]
    Number[] na = set               // passes
    def stream = Arrays.stream(1,2,3)
    int[] i = stream                // passes
  • TA 的超類別

    AbstractList list = new ArrayList()     // passes
    LinkedList list = new ArrayList()       // fails
  • TA 實作的介面

    List list = new ArrayList()             // passes
    RandomAccess list = new LinkedList()    // fails
  • TA 是原始類型,且其封裝類型可以指派

    int i = 0
    Integer bi = 1
    int x = Integer.valueOf(123)
    double d = Float.valueOf(5f)
  • T 延伸 groovy.lang.ClosureA 是 SAM 類型(單一抽象方法類型)

    Runnable r = { println 'Hello' }
    interface SAMType {
        int doSomething()
    }
    SAMType sam = { 123 }
    assert sam.doSomething() == 123
    abstract class AbstractSAM {
        int calc() { 2* value() }
        abstract int value()
    }
    AbstractSAM c = { 123 }
    assert c.calc() == 246
  • TA 衍生自 java.lang.Number,且符合下表

表 3. 數字類型 (java.lang.XXX)
T A 範例

Double

任何類型,但 BigDecimal 或 BigInteger 除外

Double d1 = 4d
Double d2 = 4f
Double d3 = 4l
Double d4 = 4i
Double d5 = (short) 4
Double d6 = (byte) 4

Float

任何類型,但 BigDecimal、BigInteger 或 Double 除外

Float f1 = 4f
Float f2 = 4l
Float f3 = 4i
Float f4 = (short) 4
Float f5 = (byte) 4

Long

任何類型,但 BigDecimal、BigInteger、Double 或 Float 除外

Long l1 = 4l
Long l2 = 4i
Long l3 = (short) 4
Long l4 = (byte) 4

Integer

任何類型,但 BigDecimal、BigInteger、Double、Float 或 Long 除外

Integer i1 = 4i
Integer i2 = (short) 4
Integer i3 = (byte) 4

Short

任何類型,但 BigDecimal、BigInteger、Double、Float、Long 或 Integer 除外

Short s1 = (short) 4
Short s2 = (byte) 4

Byte

Byte

Byte b1 = (byte) 4

6.2.3. 清單和對應建構函式

除了上述指派規則之外,如果指派在類型檢查模式中被視為無效,則當 A清單文字或對應文字時,可以指派給類型為 T 的變數,如果

  • 指派是變數宣告,且 A 是清單文字,且 T 有建構函式,其參數與清單文字中元素的類型相符

  • 指派是變數宣告,且 A 是對應文字,且 T 有無引數建構函式,且每個對應鍵都有屬性

例如,不要寫

@groovy.transform.TupleConstructor
class Person {
    String firstName
    String lastName
}
Person classic = new Person('Ada','Lovelace')

您可以使用「清單建構函式」

Person list = ['Ada','Lovelace']

或「映射建構函式」

Person map = [firstName:'Ada', lastName:'Lovelace']

如果您使用映射建構函式,則會對映射的鍵值執行額外檢查,以查看是否已定義同名的屬性。例如,以下內容會在編譯時失敗

@groovy.transform.TupleConstructor
class Person {
    String firstName
    String lastName
}
Person map = [firstName:'Ada', lastName:'Lovelace', age: 24]     (1)
1 類型檢查器會在編譯時擲出錯誤 找不到屬性:Person 類別的 age

6.2.4. 方法解析

在類型檢查模式中,方法會在編譯時解析。解析會根據名稱和引數進行。傳回類型與方法選取無關。引數類型會根據下列規則與參數類型進行配對

類型為 A 的引數 o 可用於類型為 T 的參數,當且僅當

  • T 等於 A

    int sum(int x, int y) {
        x+y
    }
    assert sum(3,4) == 7
  • TString,而 AGString

    String format(String str) {
        "Result: $str"
    }
    assert format("${3+4}") == "Result: 7"
  • o 為 null 且 T 不是原始類型

    String format(int value) {
        "Result: $value"
    }
    assert format(7) == "Result: 7"
    format(null)           // fails
  • T 是陣列且 A 是陣列,且 A 的元件類型可以指派給 T 的元件類型

    String format(String[] values) {
        "Result: ${values.join(' ')}"
    }
    assert format(['a','b'] as String[]) == "Result: a b"
    format([1,2] as int[])              // fails
  • TA 的超類別

    String format(AbstractList list) {
        list.join(',')
    }
    format(new ArrayList())              // passes
    String format(LinkedList list) {
        list.join(',')
    }
    format(new ArrayList())              // fails
  • TA 實作的介面

    String format(List list) {
        list.join(',')
    }
    format(new ArrayList())                  // passes
    String format(RandomAccess list) {
        'foo'
    }
    format(new LinkedList())                 // fails
  • TA 是原始類型,且其封裝類型可以指派

    int sum(int x, Integer y) {
        x+y
    }
    assert sum(3, new Integer(4)) == 7
    assert sum(new Integer(3), 4) == 7
    assert sum(new Integer(3), new Integer(4)) == 7
    assert sum(new Integer(3), 4) == 7
  • T 延伸 groovy.lang.ClosureA 是 SAM 類型(單一抽象方法類型)

    interface SAMType {
        int doSomething()
    }
    int twice(SAMType sam) { 2*sam.doSomething() }
    assert twice { 123 } == 246
    abstract class AbstractSAM {
        int calc() { 2* value() }
        abstract int value()
    }
    int eightTimes(AbstractSAM sam) { 4*sam.calc() }
    assert eightTimes { 123 } == 984
  • TA 衍生自 java.lang.Number,並符合與數字指派相同的規則

如果在編譯時找不到具有適當名稱和引數的方法,則會擲出錯誤。以下範例說明了與「一般」Groovy 的差異

class MyService {
    void doSomething() {
        printLine 'Do something'            (1)
    }
}
1 printLine 是錯誤,但由於我們處於動態模式,因此錯誤不會在編譯時被捕獲

上述範例顯示了一個 Groovy 能夠編譯的類別。但是,如果您嘗試建立 MyService 的執行個體並呼叫 doSomething 方法,則會在執行階段失敗,因為 printLine 不存在。當然,我們已經展示了 Groovy 如何讓這成為一個完全有效的呼叫,例如透過捕獲 MethodMissingException 或實作自訂元類別,但如果您知道自己不在這種情況下,@TypeChecked 會派上用場

@groovy.transform.TypeChecked
class MyService {
    void doSomething() {
        printLine 'Do something'            (1)
    }
}
1 printLine 這次是編譯時錯誤

只要加入 @TypeChecked,就會觸發編譯時方法解析。類型檢查器會嘗試在 MyService 類別上尋找接受 StringprintLine 方法,但找不到。它會失敗編譯,並顯示以下訊息

找不到符合的方法 MyService#printLine(java.lang.String)

了解類型檢查器背後的邏輯非常重要:它是一種編譯時檢查,因此根據定義,類型檢查器不會察覺您執行的任何類型的執行時間元程式設計。這表示在沒有 @TypeChecked 的情況下完全有效的程式碼,如果您啟用類型檢查,將無法再編譯。如果您想到鴨子型別,這一點尤其正確
class Duck {
    void quack() {              (1)
        println 'Quack!'
    }
}
class QuackingBird {
    void quack() {              (2)
        println 'Quack!'
    }
}
@groovy.transform.TypeChecked
void accept(quacker) {
    quacker.quack()             (3)
}
accept(new Duck())              (4)
1 我們定義一個 Duck 類別,它定義一個 quack 方法
2 我們定義另一個 QuackingBird 類別,它也定義一個 quack 方法
3 quacker 是鬆散型別的,因此由於這個方法是 @TypeChecked,我們會得到一個編譯時錯誤
4 即使在非類型檢查的 Groovy 中,這也會通過

有些可能的解決方法,例如引入一個介面,但基本上,透過啟用類型檢查,您獲得了類型安全性,但您失去了語言的一些功能。希望 Groovy 導入了一些功能,例如流程型別,以縮小類型檢查和非類型檢查 Groovy 之間的差距。

6.2.5. 類型推論

原則

當程式碼加上 @TypeChecked 注解時,編譯器會執行類型推論。它不僅依賴靜態類型,還使用各種技術來推論變數、回傳類型、文字… 的類型,這樣即使您啟用類型檢查器,程式碼也能保持盡可能簡潔。

最簡單的範例是推論變數的類型

def message = 'Welcome to Groovy!'              (1)
println message.toUpperCase()                   (2)
println message.upper() // compile time error   (3)
1 使用 def 關鍵字宣告變數
2 類型檢查器允許呼叫 toUpperCase
3 呼叫 upper 會在編譯時失敗

呼叫 toUpperCase 會成功的原因是 message 的類型被推論String

類型推論中的變數與欄位

值得注意的是,儘管編譯器對局部變數執行類型推論,但它不會對欄位執行任何類型的推論,始終回溯到欄位的宣告類型。為了說明這一點,我們來看這個範例

class SomeClass {
    def someUntypedField                                                                (1)
    String someTypedField                                                               (2)

    void someMethod() {
        someUntypedField = '123'                                                        (3)
        someUntypedField = someUntypedField.toUpperCase()  // compile-time error        (4)
    }

    void someSafeMethod() {
        someTypedField = '123'                                                          (5)
        someTypedField = someTypedField.toUpperCase()                                   (6)
    }

    void someMethodUsingLocalVariable() {
        def localVariable = '123'                                                       (7)
        someUntypedField = localVariable.toUpperCase()                                  (8)
    }
}
1 someUntypedField 使用 def 作為宣告類型
2 someTypedField 使用 String 作為宣告類型
3 我們可以將 任何東西 指定給 someUntypedField
4 但呼叫 toUpperCase 會在編譯時失敗,因為欄位沒有正確指定類型
5 我們可以將 String 指定給 String 類型的欄位
6 而這次 toUpperCase 被允許
7 如果我們將 String 指定給區域變數
8 則可以在區域變數上呼叫 toUpperCase

為什麼會有這樣的差異?原因在於執行緒安全性。在編譯時,我們無法對欄位的類型做出 任何 保證。任何執行緒都可以在任何時候存取任何欄位,而且在欄位在方法中指定某種類型的變數和在下一行使用該欄位之間,另一個執行緒可能已經變更欄位的內容。區域變數並非如此:我們知道它們是否「逸出」,因此我們可以確保變數的類型隨著時間保持不變(或改變)。請注意,即使欄位是 final,JVM 也不會對此做出任何保證,因此類型檢查器不會因為欄位是否為 final 而有不同的行為。

這是我們建議使用 已指定類型 欄位的原因之一。雖然由於類型推論,對區域變數使用 def 完全沒問題,但對欄位來說並非如此,欄位也屬於類別的公開 API,因此類型很重要。
集合字面值類型推論

Groovy 提供各種類型字面值的語法。Groovy 中有三個原生集合字面值

  • 清單,使用 [] 字面值

  • 對應,使用 [:] 字面值

  • 範圍,使用 from..to(包含)、from..<to(右邊不包含)、from<..to(左邊不包含)和 from<..<to(完全不包含)

字面值的推論類型取決於字面值中的元素,如下表所示

字面值 推論類型
def list = []

java.util.List

def list = ['foo','bar']

java.util.List<String>

def list = ["${foo}","${bar}"]

java.util.List<GString> 小心,GString 不是 String

def map = [:]

java.util.LinkedHashMap

def map1 = [someKey: 'someValue']
def map2 = ['someKey': 'someValue']

java.util.LinkedHashMap<String,String>

def map = ["${someKey}": 'someValue']

java.util.LinkedHashMap<GString,String>小心,金鑰是GString

def intRange = (0..10)

groovy.lang.IntRange

def charRange = ('a'..'z')

groovy.lang.Range<String>:使用邊界的類型來推斷範圍的組成類型

如你所見,除了明顯的例外IntRange,推斷的類型使用泛型類型來描述集合的內容。如果集合包含不同類型的元素,類型檢查器仍會對組成部分執行類型推斷,但使用最小上界的概念。

最小上界

在 Groovy 中,兩個類型AB最小上界定義為一個類型,其中

  • 超類別對應於AB的共同超類別

  • 介面對應於AB都實作的介面

  • 如果AB是基本類型,且A不等於B,則AB的最小上界是其封裝類型的最小上界

如果AB只有一個 (1) 個共同介面,且其共同超類別為Object,則兩者的 LUB 為共同介面。

最小上界表示可以將AB都指定到的最小類型。因此,例如,如果AB都是String,則兩者的 LUB (最小上界) 也是String

class Top {}
class Bottom1 extends Top {}
class Bottom2 extends Top {}

assert leastUpperBound(String, String) == String                    (1)
assert leastUpperBound(ArrayList, LinkedList) == AbstractList       (2)
assert leastUpperBound(ArrayList, List) == List                     (3)
assert leastUpperBound(List, List) == List                          (4)
assert leastUpperBound(Bottom1, Bottom2) == Top                     (5)
assert leastUpperBound(List, Serializable) == Object                (6)
1 StringString的 LUB 是String
2 ArrayListLinkedList的 LUB 是其共同超類型AbstractList
3 ArrayListList的 LUB 是其唯一的共同介面List
4 兩個相同介面的 LUB 是介面本身
5 Bottom1Bottom2的 LUB 是其超類別Top
6 沒有任何共同點的兩個類型的 LUB 是Object

在這些範例中,LUB 總是可以表示為 JVM 支援的常規類型。但是,Groovy 內部將 LUB 表示為一個可能更複雜的類型,而你無法使用它來定義變數。為了說明這一點,讓我們繼續這個範例

interface Foo {}
class Top {}
class Bottom extends Top implements Serializable, Foo {}
class SerializableFooImpl implements Serializable, Foo {}

BottomSerializableFooImpl的最小上界是什麼?它們沒有共同的超類別(除了Object),但它們共用 2 個介面(SerializableFoo),因此它們的最小上界是一個表示兩個介面(SerializableFoo)聯集的類型。此類型無法在原始碼中定義,但 Groovy 知道它。

在集合類型推論(以及一般泛型類型推論)的背景下,這變得方便,因為組件的類型被推論為最小上界。我們可以在以下範例說明為什麼這很重要

interface Greeter { void greet() }                  (1)
interface Salute { void salute() }                  (2)

class A implements Greeter, Salute {                (3)
    void greet() { println "Hello, I'm A!" }
    void salute() { println "Bye from A!" }
}
class B implements Greeter, Salute {                (4)
    void greet() { println "Hello, I'm B!" }
    void salute() { println "Bye from B!" }
    void exit() { println 'No way!' }               (5)
}
def list = [new A(), new B()]                       (6)
list.each {
    it.greet()                                      (7)
    it.salute()                                     (8)
    it.exit()                                       (9)
}
1 Greeter 介面定義單一方法,greet
2 Salute 介面定義單一方法,salute
3 類別 A 實作 GreeterSalute,但沒有明確的介面擴充兩者
4 B 也一樣
5 B 定義額外的 exit 方法
6 list 的類型被推論為「AB 的 LUB 清單」
7 因此可以透過 Greeter 介面呼叫在 AB 中定義的 greet
8 也可以透過 Salute 介面呼叫在 AB 中定義的 salute
9 但呼叫 exit 會產生編譯時期錯誤,因為它不屬於 AB 的 LUB(只在 B 中定義)

錯誤訊息會類似

[Static type checking] - Cannot find matching method Greeter or Salute#exit()

這表示 exit 方法既沒有在 Greeter 中定義,也沒有在 Salute 中定義,而這兩個介面是在 AB 的最小上界中定義的。

instanceof 推論

在一般未檢查類型的 Groovy 中,你可以撰寫類似這樣的內容

class Greeter {
    String greeting() { 'Hello' }
}

void doSomething(def o) {
    if (o instanceof Greeter) {     (1)
        println o.greeting()        (2)
    }
}

doSomething(new Greeter())
1 使用 instanceof 檢查來保護方法呼叫
2 執行呼叫

方法呼叫會因為動態調度而運作(方法會在執行時期選取)。Java 中的等效程式碼需要在呼叫 greeting 方法之前將 o 轉型為 Greeter,因為方法會在編譯時期選取

if (o instanceof Greeter) {
    System.out.println(((Greeter)o).greeting());
}

不過,在 Groovy 中,即使你在 doSomething 方法中加入 @TypeChecked(並因此啟用類型檢查),轉型不是必要的。編譯器會嵌入 instanceof 推論,讓轉型變成選用的。

流程類型化

流程類型化是 Groovy 在類型檢查模式中的重要概念,也是類型推論的延伸。概念是編譯器有能力推論程式碼流程中變數的類型,而不僅僅是在初始化時

@groovy.transform.TypeChecked
void flowTyping() {
    def o = 'foo'                       (1)
    o = o.toUpperCase()                 (2)
    o = 9d                              (3)
    o = Math.sqrt(o)                    (4)
}
1 首先,使用 def 宣告 o,並指定為 String
2 編譯器推斷出 oString,因此可以呼叫 toUpperCase
3 o 重新指定為 double
4 呼叫 Math.sqrt 會通過編譯,因為編譯器知道 o 在這個時候為 double

因此,類型檢查器知道變數的具體類型會隨著時間而不同。特別是,如果你將最後的指定取代為

o = 9d
o = o.toUpperCase()

類型檢查器現在會在編譯時失敗,因為它知道在呼叫 toUpperCase 時,odouble,因此會發生類型錯誤。

重要的是要了解,並非使用 def 宣告變數才會觸發類型推斷。Flow typing 可用於任何類型變數。使用明確類型宣告變數只會限制你可以指定給變數的內容

@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
    List list = ['a','b','c']           (1)
    list = list*.toUpperCase()          (2)
    list = 'foo'                        (3)
}
1 list 宣告為未檢查的 List,並指定為 `String` 的清單文字
2 這行會通過編譯,因為 Flow typing:類型檢查器知道 list 在這個時候為 List<String>
3 但你無法將 String 指定給 List,因此這會發生類型檢查錯誤

你也可以注意到,即使變數是沒有泛型資訊宣告的,類型檢查器也會知道組成類型是什麼。因此,此類程式碼會編譯失敗

@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
    List list = ['a','b','c']           (1)
    list.add(1)                         (2)
}
1 list 推斷為 List<String>
2 因此,將 int 加入 List<String> 會發生編譯時錯誤

要修正這個問題,需要在宣告中加入明確的泛型類型

@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
    List<? extends Serializable> list = []                      (1)
    list.addAll(['a','b','c'])                                  (2)
    list.add(1)                                                 (3)
}
1 list 宣告為 List<? extends Serializable>,並初始化為空清單
2 加入清單的元素符合清單的宣告類型
3 因此,將 int 加入 List<? extends Serializable> 是允許的

Flow typing 已被引入,以減少經典和靜態 Groovy 之間的語意差異。特別是,考量這段程式碼在 Java 中的行為

public Integer compute(String str) {
    return str.length();
}
public String compute(Object o) {
    return "Nope";
}
// ...
Object string = "Some string";          (1)
Object result = compute(string);        (2)
System.out.println(result);             (3)
1 o 宣告為 Object,並指定為 String
2 我們使用 o 呼叫 compute 方法
3 並印出結果

在 Java 中,這段程式碼會輸出 Nope,因為方法選擇是在編譯時期完成,且基於宣告的類型。因此,即使 o 在執行時期是 String,但呼叫的仍是 Object 版本,因為 o 已宣告為 Object。簡而言之,在 Java 中,宣告的類型最重要,不論是變數類型、參數類型或回傳類型。

在 Groovy 中,我們可以撰寫

int compute(String string) { string.length() }
String compute(Object o) { "Nope" }
Object o = 'string'
def result = compute(o)
println result

但這次,它會回傳 6,因為方法是在執行時期根據實際參數類型選擇的。因此,在執行時期,oString,所以會使用 String 變體。請注意,此行為與類型檢查無關,這是 Groovy 的一般運作方式:動態調度。

在經過類型檢查的 Groovy 中,我們希望確保類型檢查器在編譯時期選擇與執行時期會選擇相同的方法。由於語言的語意,這通常無法做到,但我們可以使用流程類型來改善。使用流程類型時,在呼叫 compute 方法時,o推論String,因此會選擇接受 String 並回傳 int 的版本。這表示我們可以推論方法的回傳類型為 int,而非 String。這對於後續呼叫和類型安全性很重要。

因此,在經過類型檢查的 Groovy 中,流程類型是一個非常重要的概念,這也表示,如果套用 @TypeChecked,方法會根據參數的推論類型選擇,而非宣告類型。這無法確保 100% 的類型安全性,因為類型檢查器可能會選擇錯誤的方法,但它確保了最接近動態 Groovy 的語意。

進階類型推論

結合流程類型最小上界推論,可執行進階類型推論,並在多種情況下確保類型安全性。特別是,程式控制結構可能會改變變數的推論類型

class Top {
   void methodFromTop() {}
}
class Bottom extends Top {
   void methodFromBottom() {}
}
def o
if (someCondition) {
    o = new Top()                               (1)
} else {
    o = new Bottom()                            (2)
}
o.methodFromTop()                               (3)
o.methodFromBottom()  // compilation error      (4)
1 如果 someCondition 為真,則將 Top 指定給 o
2 如果 someCondition 為假,則將 Bottom 指定給 o
3 呼叫 methodFromTop 是安全的
4 但呼叫 methodFromBottom 則不行,因此會產生編譯時期錯誤

當類型檢查器拜訪 if/else 控制結構時,它會檢查在 if/else 分支中指定的變數,並計算所有指定項目的 最小上界。此類型為 if/else 區塊後推斷變數的類型,因此在此範例中,oif 分支中指定為 Top,在 else 分支中指定為 Bottom。這些項目的 LUBTop,因此在條件分支後,編譯器會將 o 推斷為 Top。因此,呼叫 methodFromTop 將被允許,但呼叫 methodFromBottom 則不行。

閉包和特別是閉包共用變數也存在相同的推論。閉包共用變數是在閉包外部定義,但在閉包內部使用的變數,如下面的範例

def text = 'Hello, world!'                          (1)
def closure = {
    println text                                    (2)
}
1 宣告一個名為 text 的變數
2 text 從閉包內部使用。它是一個 閉包共用變數

Groovy 允許開發人員使用這些變數,而不需要它們為 final。這表示閉包共用變數可以在閉包內部重新指定

String result
doSomething { String it ->
    result = "Result: $it"
}
result = result?.toUpperCase()

問題在於閉包是一個獨立的程式碼區塊,可以在 任何 時間執行(或不執行)。特別是,doSomething 可能是非同步的,例如。這表示閉包的主體不屬於主要控制流程。因此,類型檢查器也會針對每個閉包共用變數計算變數的所有指定項目的 LUB,並將該 LUB 用作閉包範圍外部的推斷類型,如下面的範例

class Top {
   void methodFromTop() {}
}
class Bottom extends Top {
   void methodFromBottom() {}
}
def o = new Top()                               (1)
Thread.start {
    o = new Bottom()                            (2)
}
o.methodFromTop()                               (3)
o.methodFromBottom()  // compilation error      (4)
1 閉包共用變數首先指定為 Top
2 在閉包內部,它指定為 Bottom
3 允許 methodFromTop
4 methodFromBottom 是編譯錯誤

在此,很明顯地,當呼叫 methodFromBottom 時,在編譯時間或執行時間,無法保證 o 的類型會實際上Bottom。有機會會是,但我們無法確定,因為它是非同步的。因此,類型檢查器只會允許在 最小上界 上呼叫,這裡是 Top

6.2.6. 閉包和類型推論

類型檢查器對閉包執行特殊的推論,導致一方面有額外的檢查,另一方面則有更流暢的流動性。

回傳類型推論

類型檢查器能夠執行的第一件事是推論閉包的回傳類型。以下範例簡單地說明了這一點

@groovy.transform.TypeChecked
int testClosureReturnTypeInference(String arg) {
    def cl = { "Arg: $arg" }                                (1)
    def val = cl()                                          (2)

    val.length()                                            (3)
}
1 定義了一個閉包,它回傳一個字串(更精確地說,是一個 GString
2 我們呼叫閉包,並將結果指定給一個變數
3 類型檢查器推論出閉包會回傳一個字串,因此允許呼叫 length()

正如您所見,與明確宣告其回傳類型的函式不同,不需要宣告閉包的回傳類型:其類型會從閉包的主體推論出來。

閉包與函式

值得注意的是,回傳類型推論只適用於閉包。雖然類型檢查器可以對函式執行相同的動作,但在實務上並不可取:一般來說,函式可以被覆寫,而且在靜態上無法確定所呼叫的函式不是覆寫的版本。因此,流動類型實際上會認為函式回傳某個東西,但實際上它可能回傳其他東西,如下面的範例所示

@TypeChecked
class A {
    def compute() { 'some string' }             (1)
    def computeFully() {
        compute().toUpperCase()                 (2)
    }
}
@TypeChecked
class B extends A {
    def compute() { 123 }                       (3)
}
1 類別 A 定義了一個函式 compute,實際上回傳一個 String
2 這會導致編譯失敗,因為 compute 的回傳類型是 def(又稱 Object
3 類別 B 延伸 A 並重新定義 compute,這個類型回傳一個 int

正如您所見,如果類型檢查器依賴函式的推論回傳類型,使用 流動類型,類型檢查器可以確定呼叫 toUpperCase 是可以的。事實上,這是一個錯誤,因為子類別可以覆寫 compute 並回傳不同的物件。在此,B#compute 回傳一個 int,因此在 B 的執行個體上呼叫 computeFully 的人會看到執行時間錯誤。編譯器透過使用函式的宣告回傳類型,而不是推論回傳類型,來防止這種情況發生。

為了保持一致性,此行為對每個方法都相同,即使它們是靜態或最終的。

參數類型推論

除了回傳類型之外,閉包還可以從上下文中推論其參數類型。編譯器有兩種方法可以推論參數類型

  • 透過隱式 SAM 類型強制轉換

  • 透過 API 元資料

為了說明這一點,讓我們從一個範例開始,由於類型檢查器無法推論參數類型,因此編譯會失敗

class Person {
    String name
    int age
}

void inviteIf(Person p, Closure<Boolean> predicate) {           (1)
    if (predicate.call(p)) {
        // send invite
        // ...
    }
}

@groovy.transform.TypeChecked
void failCompilation() {
    Person p = new Person(name: 'Gerard', age: 55)
    inviteIf(p) {                                               (2)
        it.age >= 18 // No such property: age                   (3)
    }
}
1 inviteIf 方法接受 PersonClosure
2 我們使用 PersonClosure 呼叫它
3 it 在靜態上並不知道是 Person,因此編譯失敗

在此範例中,閉包主體包含 it.age。對於動態的、未類型檢查的程式碼,這會運作,因為 it 的類型在執行階段會是 Person。不幸的是,在編譯階段,沒有辦法僅透過讀取 inviteIf 的簽章就能知道 it 的類型。

明確的閉包參數

簡而言之,類型檢查器沒有足夠的 inviteIf 方法的上下文資訊,無法在靜態上判斷 it 的類型。這表示需要像這樣重新撰寫方法呼叫

inviteIf(p) { Person it ->                                  (1)
    it.age >= 18
}
1 it 的類型需要明確宣告

透過明確宣告 it 變數的類型,您可以解決問題,並使此程式碼在靜態上受到檢查。

從單一抽象方法類型推論的參數

對於 API 或架構設計者,有兩種方法可以讓使用者更優雅地使用,讓他們不必為閉包參數宣告明確的類型。第一個也是最簡單的方法,就是將閉包替換為 SAM 類型

interface Predicate<On> { boolean apply(On e) }                 (1)

void inviteIf(Person p, Predicate<Person> predicate) {          (2)
    if (predicate.apply(p)) {
        // send invite
        // ...
    }
}

@groovy.transform.TypeChecked
void passesCompilation() {
    Person p = new Person(name: 'Gerard', age: 55)

    inviteIf(p) {                                               (3)
        it.age >= 18                                            (4)
    }
}
1 宣告一個具有 apply 方法的 SAM 介面
2 inviteIf 現在使用 Predicate<Person> 而不是 Closure<Boolean>
3 不再需要宣告 it 變數的類型
4 it.age 編譯正確,it 的類型從 Predicate#apply 方法簽章推論
透過使用此技術,我們利用了 Groovy 的自動將閉包強制轉換為 SAM 類型功能。您是否應該使用SAM 類型閉包實際上取決於您需要做什麼。在許多情況下,使用 SAM 介面就足夠了,特別是如果您將函式介面視為在 Java 8 中找到的函式介面時。但是,閉包提供了函式介面無法存取的功能。特別是,閉包可以具有委派、擁有者,並且可以在呼叫之前作為物件進行處理(例如,複製、序列化、柯里化,…​)。它們還可以支援多個簽章(多型性)。因此,如果您需要這種處理,最好切換到下面描述的最進階類型推論註解。

在閉包參數類型推論中需要解決的原始問題,也就是說,在沒有明確宣告參數類型的情況下,靜態判斷閉包參數的類型,在於 Groovy 類型系統繼承了 Java 類型系統,而 Java 類型系統不足以描述參數的類型。

@ClosureParams 註解

Groovy 提供一個註解 @ClosureParams,用於完成類型資訊。此註解主要針對框架和 API 開發人員,他們希望透過提供類型推論的元資料來擴充類型檢查器的功能。如果你的程式庫使用閉包,而且你希望獲得最高層級的工具支援,這一點非常重要。

讓我們透過修正原始範例來說明這一點,並加入 @ClosureParams 註解

import groovy.transform.stc.ClosureParams
import groovy.transform.stc.FirstParam
void inviteIf(Person p, @ClosureParams(FirstParam) Closure<Boolean> predicate) {        (1)
    if (predicate.call(p)) {
        // send invite
        // ...
    }
}
inviteIf(p) {                                                                       (2)
    it.age >= 18
}
1 閉包參數加上 @ClosureParams 註解
2 不需要為 it 使用明確的類型,因為會推論出來

@ClosureParams 註解至少接受一個引數,稱為「類型提示」。類型提示是一個類別,負責在編譯時為閉包完成類型資訊。在此範例中,使用的類型提示是 groovy.transform.stc.FirstParam,它向類型檢查器表示閉包會接受一個參數,其類型是方法第一個參數的類型。在此情況下,方法的第一個參數是 Person,因此它向類型檢查器表示閉包的第一個參數實際上是 Person

第二個選用引數稱為「選項」。其語意取決於「類型提示」類別。Groovy 附帶各種已綑綁的類型提示,如下表所示

表 4. 預先定義的類型提示
類型提示 多型? 說明和範例

FirstParam
SecondParam
ThirdParam

方法的第一個(分別為第二個、第三個)參數類型

import groovy.transform.stc.FirstParam
void doSomething(String str, @ClosureParams(FirstParam) Closure c) {
    c(str)
}
doSomething('foo') { println it.toUpperCase() }
import groovy.transform.stc.SecondParam
void withHash(String str, int seed, @ClosureParams(SecondParam) Closure c) {
    c(31*str.hashCode()+seed)
}
withHash('foo', (int)System.currentTimeMillis()) {
    int mod = it%2
}
import groovy.transform.stc.ThirdParam
String format(String prefix, String postfix, String o, @ClosureParams(ThirdParam) Closure c) {
    "$prefix${c(o)}$postfix"
}
assert format('foo', 'bar', 'baz') {
    it.toUpperCase()
} == 'fooBAZbar'

FirstParam.FirstGenericType
SecondParam.FirstGenericType
ThirdParam.FirstGenericType

方法的第一個(分別為第二個、第三個)參數的第一個泛型類型

import groovy.transform.stc.FirstParam
public <T> void doSomething(List<T> strings, @ClosureParams(FirstParam.FirstGenericType) Closure c) {
    strings.each {
        c(it)
    }
}
doSomething(['foo','bar']) { println it.toUpperCase() }
doSomething([1,2,3]) { println(2*it) }

所有 FirstParamSecondParamThirdParam 類型提示都存在 SecondGenericTypeThirdGenericType 的變體。

SimpleType

一個類型提示,其閉包參數的類型來自選項字串。

import groovy.transform.stc.SimpleType
public void doSomething(@ClosureParams(value=SimpleType,options=['java.lang.String','int']) Closure c) {
    c('foo',3)
}
doSomething { str, len ->
    assert str.length() == len
}

此類型提示支援**單一**簽章,且每個參數都使用完全限定類型名稱或基本類型,指定為 options 陣列的值。

MapEntryOrKeyValue

專門針對封閉函式的類型提示,封閉函式可使用單一參數的 Map.Entry,或兩個對應於鍵和值的參數。

import groovy.transform.stc.MapEntryOrKeyValue
public <K,V> void doSomething(Map<K,V> map, @ClosureParams(MapEntryOrKeyValue) Closure c) {
    // ...
}
doSomething([a: 'A']) { k,v ->
    assert k.toUpperCase() == v.toUpperCase()
}
doSomething([abc: 3]) { e ->
    assert e.key.length() == e.value
}

此類型提示需要第一個參數為 Map 類型,並從實際鍵/值類型推論封閉函式參數類型。

FromAbstractTypeMethods

從某種類型的抽象方法推論封閉函式參數類型。每個抽象方法都會推論出一個簽章。

import groovy.transform.stc.FromAbstractTypeMethods
abstract class Foo {
    abstract void firstSignature(int x, int y)
    abstract void secondSignature(String str)
}
void doSomething(@ClosureParams(value=FromAbstractTypeMethods, options=["Foo"]) Closure cl) {
    // ...
}
doSomething { a, b -> a+b }
doSomething { s -> s.toUpperCase() }

如果有多個簽章,如以上範例,類型檢查器只能在每個方法的元數不同時推論參數類型。在以上範例中,firstSignature 使用 2 個參數,而 secondSignature 使用 1 個參數,因此類型檢查器可以根據參數數量推論參數類型。但請參閱接下來討論的選用解析器類別屬性。

FromString

options 參數推論封閉函式參數類型。options 參數包含一個逗號分隔的非原始類型的陣列。陣列的每個元素對應於一個簽章,元素中的每個逗號分隔簽章的參數。簡而言之,這是最通用的類型提示,options 地圖的每個字串都會解析,就像它是簽章文字一樣。雖然非常強大,但如果可以的話,必須避免此類型提示,因為它會增加編譯時間,因為需要解析類型簽章。

接受 String 的封閉函式的單一簽章

import groovy.transform.stc.FromString
void doSomething(@ClosureParams(value=FromString, options=["String","String,Integer"]) Closure cl) {
    // ...
}
doSomething { s -> s.toUpperCase() }
doSomething { s,i -> s.toUpperCase()*i }

接受 StringString, Integer 的多型封閉函式

import groovy.transform.stc.FromString
void doSomething(@ClosureParams(value=FromString, options=["String","String,Integer"]) Closure cl) {
    // ...
}
doSomething { s -> s.toUpperCase() }
doSomething { s,i -> s.toUpperCase()*i }

接受 T 或一對 T,T 的多型封閉函式

import groovy.transform.stc.FromString
public <T> void doSomething(T e, @ClosureParams(value=FromString, options=["T","T,T"]) Closure cl) {
    // ...
}
doSomething('foo') { s -> s.toUpperCase() }
doSomething('foo') { s1,s2 -> assert s1.toUpperCase() == s2.toUpperCase() }
即使您使用 FirstParamSecondParamThirdParam 作為類型提示,這並不表示傳遞給閉包的參數是方法呼叫的第一個(分別是第二個、第三個)參數。這只表示閉包參數的類型會與方法呼叫的第一個(分別是第二個、第三個)參數的類型相同

簡而言之,接受 Closure 的方法中缺少 @ClosureParams 註解不會導致編譯失敗。如果存在(它可以在 Java 來源以及 Groovy 來源中存在),則類型檢查器有更多資訊,並且可以執行額外的類型推論。這使得此功能對架構開發人員特別有趣。

第三個選用參數稱為 conflictResolutionStrategy。它可以參照一個類別(從 ClosureSignatureConflictResolver 延伸),如果在初始推論計算完成後找到多個參數類型,它可以執行額外的參數類型解析。Groovy 附帶一個什麼都不做的預設類型解析器,以及一個如果找到多個簽章,則選擇第一個簽章的解析器。解析器僅在找到多個簽章時才會被呼叫,並且根據設計是一個後處理器。任何需要注入類型資訊的陳述式都必須傳遞透過類型提示確定的參數簽章之一。然後解析器會從回傳的候選簽章中挑選。

@DelegatesTo

類型檢查器使用 @DelegatesTo 註解來推論委派的類型。它允許 API 設計者指示編譯器委派的類型和委派策略。@DelegatesTo 註解在特定區段中討論。

6.3. 靜態編譯

6.3.1. 動態與靜態

類型檢查區段中,我們已經看到 Groovy 提供了選用的類型檢查,這要感謝 @TypeChecked 註解。類型檢查器在編譯時執行,並對動態程式碼執行靜態分析。無論是否啟用類型檢查,程式都會表現得完全相同。這表示 @TypeChecked 註解對於程式的語意是中立的。即使可能需要在來源中加入類型資訊,以便程式被視為類型安全,但最後程式的語意是相同的。

雖然這聽起來很好,但實際上有一個問題:在編譯時執行的動態程式碼類型檢查,根據定義,只有在沒有發生特定於執行時期的行為時才是正確的。例如,下列程式通過類型檢查

class Computer {
    int compute(String str) {
        str.length()
    }
    String compute(int x) {
        String.valueOf(x)
    }
}

@groovy.transform.TypeChecked
void test() {
    def computer = new Computer()
    computer.with {
        assert compute(compute('foobar')) =='6'
    }
}

有兩個 compute 方法。一個接受 String 並回傳 int,另一個接受 int 並回傳 String。如果您編譯這個,它被視為類型安全:內部的 compute('foobar') 呼叫會回傳 int,而對此 int 呼叫 compute 將會回傳 String

現在,在呼叫 test() 之前,請考慮加入下列程式碼行

Computer.metaClass.compute = { String str -> new Date() }

使用執行時期元程式設計,我們實際上修改了 compute(String) 方法的行為,因此它不會傳回所提供參數的長度,而是傳回 Date。如果您執行程式,它會在執行時期失敗。由於此程式碼行可以從任何位置、任何執行緒中加入,因此類型檢查器絕對無法靜態確保不會發生此類事情。簡而言之,類型檢查器容易受到猴子修補的影響。這只是一個範例,不過它說明了對動態程式進行靜態分析的概念在根本上是錯誤的。

Groovy 語言提供了一個替代註解 @TypeChecked,它實際上會確保推論為已呼叫的方法會在執行時期有效地呼叫。此註解將 Groovy 編譯器轉換為靜態編譯器,其中所有方法呼叫都在編譯時期解析產生的位元組碼確保會發生這種情況:註解為 @groovy.transform.CompileStatic

6.3.2. @CompileStatic 註解

可以在 @TypeChecked 註解可使用的任何位置加入 @CompileStatic 註解,也就是說在類別或方法上。不需要同時加入 @TypeChecked@CompileStatic,因為 @CompileStatic 會執行 @TypeChecked 所做的一切,但此外還會觸發靜態編譯。

讓我們來看看失敗的範例,但這次我們用 @CompileStatic 註解取代 @TypeChecked 註解

class Computer {
    int compute(String str) {
        str.length()
    }
    String compute(int x) {
        String.valueOf(x)
    }
}

@groovy.transform.CompileStatic
void test() {
    def computer = new Computer()
    computer.with {
        assert compute(compute('foobar')) =='6'
    }
}
Computer.metaClass.compute = { String str -> new Date() }
test()

這是唯一的差異。如果我們執行此程式,這次不會有執行時期錯誤。test 方法對猴子修補產生了免疫力,因為在主體中呼叫的 compute 方法會在編譯時期連結,因此即使 Computer 的元類別變更,程式仍然會如類型檢查器預期的那樣執行。

6.3.3. 主要優點

在您的程式碼上使用 @CompileStatic 有幾個優點

效能改善取決於您執行的程式類型。如果是 I/O 繫結,靜態編譯程式碼和動態程式碼之間的差異幾乎不顯著。在高度 CPU 密集的程式碼中,由於產生的位元組碼非常接近(如果不是相等的話)Java 會為等效程式產生的位元組碼,因此效能會大幅提升。

使用 Groovy 的 invokedynamic 版本,這對使用 JDK 7 以上版本的人來說是可以存取的,動態程式碼的效能應該非常接近靜態編譯程式碼的效能。有時,它甚至可以更快!只有一種方法可以確定您應該選擇哪個版本:測量。原因是,根據您的程式 您使用的 JVM,效能可能會顯著不同。特別是,Groovy 的 invokedynamic 版本對所使用的 JVM 版本非常敏感。

7. 型別檢查擴充

7.1. 編寫型別檢查擴充

7.1.1. 朝向更智慧的型別檢查器

儘管 Groovy 是一種動態語言,但它可以在編譯時與 靜態型別檢查器 一起使用,使用 @TypeChecked 注解啟用。在此模式下,編譯器會變得更詳細,並針對例如拼寫錯誤、不存在的方法等情況擲回錯誤。不過,這會帶來一些限制,其中大部分來自於 Groovy 本質上仍然是一種動態語言的事實。例如,您將無法對使用標記建構器的程式碼使用型別檢查

def builder = new MarkupBuilder(out)
builder.html {
    head {
        // ...
    }
    body {
        p 'Hello, world!'
    }
}

在前面的範例中,htmlheadbodyp 方法都不存在。但是,如果您執行程式碼,它會運作,因為 Groovy 使用動態調度並在執行時轉換那些方法呼叫。在此建構器中,對於您可以使用的標籤數量或屬性沒有限制,這表示型別檢查器在編譯時不可能知道所有可能的方法(標籤),除非您專門為 HTML 建立一個建構器,例如。

在實作內部 DSL 時,Groovy 是首選的平台。靈活的語法,結合執行時和編譯時元程式設計功能,使 Groovy 成為一個有趣的選擇,因為它允許程式設計師專注於 DSL,而不是工具或實作。由於 Groovy DSL 是 Groovy 程式碼,因此很容易獲得 IDE 支援,而無需編寫專用外掛程式,例如。

在許多情況下,DSL 引擎是用 Groovy(或 Java)編寫,然後使用者程式碼會以指令碼執行,這表示您在使用者邏輯上有一些包裝器。包裝器可能包含在 GroovyShellGroovyScriptEngine 中,在執行指令碼之前會透明地執行一些任務(新增匯入、套用 AST 轉換、延伸基礎指令碼等)。通常,使用者撰寫的指令碼會在未經測試的情況下進入生產,因為 DSL 邏輯會到達一個點,讓任何使用者都可以使用 DSL 語法撰寫程式碼。最後,使用者可能只是忽略他們所寫的內容實際上是程式碼。這為 DSL 實作人員帶來了一些挑戰,例如確保使用者程式碼的執行安全性,或在本例中,提早回報錯誤。

例如,想像一個 DSL,其目標是遠端驅動火星上的探測車。傳送訊息給探測車需要大約 15 分鐘。如果探測車執行指令碼並因錯誤(例如拼寫錯誤)而失敗,您會有兩個問題

  • 首先,回饋只會在 30 分鐘後出現(探測車取得指令碼所需的時間加上接收錯誤所需的時間)

  • 其次,指令碼的一部分已經執行,而且您可能必須大幅變更已修正的指令碼(表示您需要知道探測車的目前狀態…)

類型檢查延伸是讓 DSL 引擎開發人員可以透過套用靜態類型檢查允許在一般 groovy 類別中執行的相同檢查,讓這些指令碼更安全的機制。

此處的原則是提早失敗,也就是盡快讓指令碼編譯失敗,並在可能的情況下提供回饋給使用者(包括友善的錯誤訊息)。

簡而言之,類型檢查延伸背後的概念是讓編譯器知道 DSL 使用的所有執行階段元程式設計技巧,讓指令碼可以受益於與詳細靜態編譯程式碼相同的編譯時間檢查層級。我們將看到您可以透過執行一般類型檢查器不會執行的檢查,進一步提供強大的編譯時間檢查給使用者。

7.1.2. extensions 屬性

@TypeChecked 標記支援一個名為 extensions 的屬性。此參數會取得一個字串陣列,對應於類型檢查延伸指令碼清單。這些指令碼會在編譯時間在類別路徑中找到。例如,您會撰寫

@TypeChecked(extensions='/path/to/myextension.groovy')
void foo() { ...}

在這種情況下,foo 方法會使用一般類型檢查器的規則進行類型檢查,並由 myextension.groovy 指令碼中找到的規則完成。請注意,雖然類型檢查器在內部支援多種機制來實作類型檢查延伸(包括純舊 Java 程式碼),但建議的方式是使用這些類型檢查延伸指令碼。

7.1.3. 用於類型檢查的 DSL

類型檢查擴充功能背後的想法是使用 DSL 來擴充類型檢查器功能。這個 DSL 允許您使用「事件驅動」API 連結到編譯流程,更具體地說是類型檢查階段。例如,當類型檢查器進入方法主體時,它會擲出擴充功能可以反應的 beforeVisitMethod 事件

beforeVisitMethod { methodNode ->
 println "Entering ${methodNode.name}"
}

想像一下您手邊有這個 rover DSL。使用者會撰寫

robot.move 100

如果您有一個這樣定義的類別

class Robot {
    Robot move(int qt) { this }
}

可以在執行腳本前使用下列腳本來類型檢查腳本

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
    new ASTTransformationCustomizer(TypeChecked)            (1)
)
def shell = new GroovyShell(config)                         (2)
def robot = new Robot()
shell.setVariable('robot', robot)
shell.evaluate(script)                                      (3)
1 編譯器組態會將 @TypeChecked 註解新增到所有類別
2 GroovyShell 中使用組態
3 以便使用 shell 編譯的腳本會使用 @TypeChecked 編譯,而使用者不必明確新增

使用上述編譯器組態,我們可以透明地將 @TypeChecked 套用至腳本。在這種情況下,它會在編譯時失敗

[Static type checking] - The variable [robot] is undeclared.

現在,我們會稍微更新組態以包含 ``extensions'' 參數

config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        TypeChecked,
        extensions:['robotextension.groovy'])
)

然後將下列內容新增到您的類別路徑

robotextension.groovy
unresolvedVariable { var ->
    if ('robot'==var.name) {
        storeType(var, classNodeFor(Robot))
        handled = true
    }
}

在此,我們告訴編譯器,如果找到一個未解析變數,而且變數名稱是 robot,那麼我們可以確定這個變數的類型是 Robot

7.1.4. 類型檢查擴充功能 API

AST

類型檢查 API 是低階 API,處理抽象語法樹。您必須深入了解您的 AST 才能開發擴充功能,即使 DSL 讓它比直接處理來自純 Java 或 Groovy 的 AST 程式碼容易許多。

事件

類型檢查器會傳送下列事件,擴充功能腳本可以對其反應

事件名稱

setup

何時呼叫

在類型檢查器完成初始化後呼叫

引數

用法

setup {
    // this is called before anything else
}

可用於執行擴充功能的設定

事件名稱

finish

何時呼叫

類型檢查器完成類型檢查後呼叫

引數

用法

finish {
    // this is after completion
    // of all type checking
}

可以在類型檢查器完成工作後用來執行其他檢查。

事件名稱

unresolvedVariable

何時呼叫

類型檢查器找到未解析變數時呼叫

引數

變數表達式 vexp

用法

unresolvedVariable { VariableExpression vexp ->
    if (vexp.name == 'people') {
        storeType(vexp, LIST_TYPE)
        handled = true
    }
}

允許開發人員協助類型檢查器處理使用者注入的變數。

事件名稱

unresolvedProperty

何時呼叫

類型檢查器在接收器上找不到屬性時呼叫

引數

屬性表達式 pexp

用法

unresolvedProperty { PropertyExpression pexp ->
    if (pexp.propertyAsString == 'longueur' &&
            getType(pexp.objectExpression) == STRING_TYPE) {
        storeType(pexp, int_TYPE)
        handled = true
    }
}

允許開發人員處理「動態」屬性

事件名稱

unresolvedAttribute

何時呼叫

類型檢查器在接收器上找不到屬性時呼叫

引數

屬性表達式 aexp

用法

unresolvedAttribute { AttributeExpression aexp ->
    if (getType(aexp.objectExpression) == STRING_TYPE) {
        storeType(aexp, STRING_TYPE)
        handled = true
    }
}

允許開發人員處理遺失的屬性

事件名稱

beforeMethodCall

何時呼叫

類型檢查器開始類型檢查方法呼叫之前呼叫

引數

方法呼叫 call

用法

beforeMethodCall { call ->
    if (isMethodCallExpression(call)
            && call.methodAsString=='toUpperCase') {
        addStaticTypeError('Not allowed',call)
        handled = true
    }
}

允許您在類型檢查器執行自己的檢查之前攔截方法呼叫。如果您想要替換預設類型檢查,使其在有限範圍內執行自訂檢查,這會很有用。在這種情況下,您必須將已處理旗標設定為 true,以便類型檢查器略過自己的檢查。

事件名稱

afterMethodCall

何時呼叫

類型檢查器完成方法呼叫的類型檢查後呼叫

引數

方法呼叫 call

用法

afterMethodCall { call ->
    if (getTargetMethod(call).name=='toUpperCase') {
        addStaticTypeError('Not allowed',call)
        handled = true
    }
}

允許您在類型檢查器完成自己的檢查後執行其他檢查。如果您想要執行標準類型檢查測試,但同時也想要確保其他類型安全,例如檢查引數彼此之間的關係,這會特別有用。請注意,即使您執行了 beforeMethodCall 並將已處理旗標設定為 true,也會呼叫 afterMethodCall

事件名稱

onMethodSelection

何時呼叫

類型檢查器找到適合方法呼叫的方法時呼叫

引數

表達式 expr,方法節點 node

用法

onMethodSelection { expr, node ->
    if (node.declaringClass.name == 'java.lang.String') {
        // calling a method on 'String'
        // let’s perform additional checks!
        if (++count>2) {
            addStaticTypeError("You can use only 2 calls on String in your source code",expr)
        }
    }
}

類型檢查器透過推論方法呼叫的引數類型來運作,然後選擇目標方法。如果它找到相符的方法,就會觸發這個事件。例如,如果您想要對特定方法呼叫做出反應,例如進入將封閉作為引數的方法範圍(如建構函式),這會很有趣。請注意,這個事件可能會因各種類型的表達式而觸發,而不僅限於方法呼叫(例如二元表達式)。

事件名稱

methodNotFound

何時呼叫

當類型檢查器找不到適當的方法來進行方法呼叫時呼叫

引數

ClassNode 接收器、String 名稱、ArgumentListExpression argList、ClassNode[] argTypes、MethodCall 呼叫

用法

methodNotFound { receiver, name, argList, argTypes, call ->
    // receiver is the inferred type of the receiver
    // name is the name of the called method
    // argList is the list of arguments the method was called with
    // argTypes is the array of inferred types for each argument
    // call is the method call for which we couldn’t find a target method
    if (receiver==classNodeFor(String)
            && name=='longueur'
            && argList.size()==0) {
        handled = true
        return newMethod('longueur', classNodeFor(String))
    }
}

onMethodSelection 不同,此事件會在類型檢查器找不到方法呼叫(實例或靜態)的目標方法時傳送。它讓您在錯誤傳送給使用者之前攔截錯誤,但也可以設定目標方法。為此,您需要傳回 MethodNode 清單。在大部分情況下,您會傳回:空清單,表示您找不到對應的方法、只有一個元素的清單,表示目標方法毫無疑問。如果您傳回多個 MethodNode,編譯器會對使用者傳送錯誤,指出方法呼叫不明確,並列出可能的方法。為方便起見,如果您只想傳回一個方法,您可以直接傳回,而不用將其包裝到清單中。

事件名稱

beforeVisitMethod

何時呼叫

在類型檢查器類型檢查方法主體之前呼叫

引數

MethodNode 節點

用法

beforeVisitMethod { methodNode ->
    // tell the type checker we will handle the body by ourselves
    handled = methodNode.name.startsWith('skip')
}

類型檢查器會在開始類型檢查方法主體之前呼叫此方法。例如,如果您想要自行執行類型檢查,而不是讓類型檢查器執行,您必須將處理旗標設定為 true。此事件也可以用來協助定義擴充功能的範圍(例如,僅在您位於方法 foo 內部時套用)。

事件名稱

afterVisitMethod

何時呼叫

在類型檢查器類型檢查方法主體之後呼叫

引數

MethodNode 節點

用法

afterVisitMethod { methodNode ->
    scopeExit {
        if (methods>2) {
            addStaticTypeError("Method ${methodNode.name} contains more than 2 method calls", methodNode)
        }
    }
}

讓您在類型檢查器拜訪方法主體之後執行其他檢查。如果您收集資訊,例如,並希望在收集所有內容後執行其他檢查,這會很有用。

事件名稱

beforeVisitClass

何時呼叫

在類型檢查器類型檢查類別之前呼叫

引數

ClassNode 節點

用法

beforeVisitClass { ClassNode classNode ->
    def name = classNode.nameWithoutPackage
    if (!(name[0] in 'A'..'Z')) {
        addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode)
    }
}

如果類型檢查類別,則在拜訪類別之前,會傳送此事件。對於在註解為 @TypeChecked 的類別內定義的內部類別也是如此。它可以協助您定義擴充功能的範圍,或者您甚至可以使用自訂類型檢查實作完全取代類型檢查器的拜訪。為此,您必須將 handled 旗標設定為 true。 

事件名稱

afterVisitClass

何時呼叫

在完成類型檢查類別的拜訪後,由類型檢查器呼叫

引數

ClassNode 節點

用法

afterVisitClass { ClassNode classNode ->
    def name = classNode.nameWithoutPackage
    if (!(name[0] in 'A'..'Z')) {
        addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode)
    }
}

在類型檢查器完成其工作後,針對每個類型檢查的類別呼叫。這包括註解為 @TypeChecked 的類別,以及在未略過的同一個類別中定義的任何內部/匿名類別。

事件名稱

incompatibleAssignment

何時呼叫

當類型檢查器認為指定不正確時呼叫,表示指定右側與左側不相容

引數

ClassNode lhsType、ClassNode rhsType、Expression 指定

用法

incompatibleAssignment { lhsType, rhsType, expr ->
    if (isBinaryExpression(expr) && isAssignment(expr.operation.type)) {
        if (lhsType==classNodeFor(int) && rhsType==classNodeFor(Closure)) {
            handled = true
        }
    }
}

提供開發人員處理錯誤指派的可能性。例如,如果類別覆寫 `setProperty`,則在這種情況下,可以透過執行時期機制來處理將一種型別的變數指派給另一種型別的屬性。在這種情況下,只要告知類型檢查器指派有效(使用 `handled` 設定為 `true`),即可協助類型檢查器。

事件名稱

incompatibleReturnType

何時呼叫

在類型檢查器認為傳回值與封閉或方法的傳回值不相容時呼叫

引數

ReturnStatement 陳述式、ClassNode valueType

用法

incompatibleReturnType { stmt, type ->
    if (type == STRING_TYPE) {
        handled = true
    }
}

提供開發人員處理錯誤傳回值的可能性。例如,當傳回值將進行隱式轉換或封閉的目標型別難以正確推論時,這很有用。在這種情況下,只要告知類型檢查器指派有效(透過設定 `handled` 屬性),即可協助類型檢查器。

事件名稱

ambiguousMethods

何時呼叫

在類型檢查器無法在多個候選方法之間進行選擇時呼叫

引數

List<MethodNode> 方法、Expression 起始點

用法

ambiguousMethods { methods, origin ->
    // choose the method which has an Integer as parameter type
    methods.find { it.parameters.any { it.type == classNodeFor(Integer) } }
}

提供開發人員處理錯誤指派的可能性。例如,如果類別覆寫 `setProperty`,則在這種情況下,可以透過執行時期機制來處理將一種型別的變數指派給另一種型別的屬性。在這種情況下,只要告知類型檢查器指派有效(使用 `handled` 設定為 `true`),即可協助類型檢查器。

當然,延伸指令碼可能包含多個區塊,而且可以有多個區塊回應相同的事件。這使得 DSL 看起來更漂亮且更容易撰寫。不過,回應事件遠遠不夠。如果您知道可以回應事件,您也需要處理錯誤,這表示需要多種會讓事情變得更簡單的輔助方法。

7.1.5. 使用延伸功能

支援類別

DSL 依賴稱為 org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport 的支援類別。此類別本身會延伸 org.codehaus.groovy.transform.stc.TypeCheckingExtension。這兩個類別定義了多種輔助方法,這些方法會讓使用 AST 變得更簡單,特別是在類型檢查方面。值得知道的一件有趣的事是,您可以存取類型檢查器。這表示您可以以程式方式呼叫類型檢查器的方法,包括允許您擲出編譯錯誤的方法。

延伸指令碼委派給 org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport 類別,表示您可以直接存取下列變數

類型檢查環境包含許多對類型檢查器有用的資訊。例如,封裝方法呼叫、二元表達式、封閉的目前堆疊… 如果你必須知道錯誤發生時你 在哪裡 且你想要處理它,此資訊特別重要。

除了 GroovyTypeCheckingExtensionSupportStaticTypeCheckingVisitor 提供的工具外,類型檢查 DSL 程式碼會從 org.codehaus.groovy.ast.ClassHelperorg.codehaus.groovy.transform.stc.StaticTypeCheckingSupport 匯入靜態成員,透過 OBJECT_TYPESTRING_TYPETHROWABLE_TYPE 等存取常見類型,並檢查例如 missesGenericsTypes(ClassNode)isClassClassNodeWrappingConcreteType(ClassNode) 等。

類別節點

處理類別節點是你使用類型檢查擴充時需要特別注意的事項。編譯會使用抽象語法樹 (AST),且在你類型檢查類別時,樹狀結構可能不完整。這也表示當你參照類型時,你不得使用類別文字,例如 StringHashSet,而是使用代表這些類型的類別節點。這需要具備一定程度的抽象化,並了解 Groovy 如何處理類別節點。為了讓事情更簡單,Groovy 提供了多個輔助方法來處理類別節點。例如,如果你想要說「String 的類型」,你可以寫

assert classNodeFor(String) instanceof ClassNode

你還會注意到有一個 classNodeFor 變體,它將 String 作為引數,而不是 Class。一般來說,你 不應 使用它,因為它會建立一個名稱為 String 的類別節點,但沒有任何方法或任何屬性定義在其中。第一個版本會傳回已 解析 的類別節點,但第二個版本會傳回 解析的類別節點。因此,後者應保留在非常特殊的情況下使用。

你可能會遇到的第二個問題是參照尚未編譯的類型。這發生的頻率可能比你想像的還要高。例如,當你同時編譯一組檔案時。在這種情況下,如果你想要說「該變數的類型為 Foo」,但 Foo 尚未編譯,你仍然可以使用 lookupClassNodeFor 參照 Foo 類別節點

assert lookupClassNodeFor('Foo') instanceof ClassNode
協助類型檢查器

假設您知道變數 foo 是 Foo 類型,而且您想告訴類型檢查器。然後,您可以使用 storeType 方法,它有兩個參數:第一個是您要儲存類型的節點,第二個是節點的類型。如果您查看 storeType 的實作,您會看到它委派給類型檢查器等效的方法,它本身會執行大量工作來儲存節點的元資料。您還會看到,儲存類型不限於變數:您可以設定任何表達式的類型。

同樣地,取得 AST 節點的類型只是呼叫該節點上的 getType。這通常是您想要的,但您必須了解一些事情

  • getType 會傳回表達式的 推論類型。這表示它不會為宣告為 Object 類型的變數傳回 Object 的類別節點,而是此變數在 程式碼的這個點(流程類型化)的推論類型

  • 如果您想存取變數(或欄位/參數)的原始類型,則必須呼叫 AST 節點上的適當方法

擲回錯誤

若要擲回類型檢查錯誤,您只需呼叫 addStaticTypeError 方法,它有兩個參數

  • 訊息,這是會顯示給最終使用者的字串

  • AST 節點,負責錯誤。最好提供最適合的 AST 節點,因為它會用於擷取行號和欄號

isXXXExpression

通常需要知道 AST 節點的類型。為了可讀性,DSL 提供特殊的 isXXXExpression 方法,它會委派給 x instance of XXXExpression。例如,您可以直接撰寫

if (node instanceof BinaryExpression) {
   ...
}

而不是撰寫

if (isBinaryExpression(node)) {
   ...
}
虛擬方法

當您執行動態程式碼的類型檢查時,您可能會經常遇到一種情況,即您知道方法呼叫有效,但其背後沒有「實際」的方法。舉例來說,請看 Grails 動態尋找器。您可以有一個方法呼叫,包含名為 findByName(…) 的方法。由於 bean 中沒有定義 findByName 方法,因此類型檢查器會抱怨。然而,您會知道此方法在執行階段不會失敗,您甚至可以判斷此方法的回傳類型。對於這種情況,DSL 支援兩個由幻影方法組成的特殊建構。這表示您將回傳一個實際上不存在,但定義在類型檢查內容中的方法節點。存在三種方法

  • newMethod(String name, Class returnType)

  • newMethod(String name, ClassNode returnType)

  • newMethod(String name, Callable<ClassNode> return Type)

所有這三種變體執行相同動作:它們建立一個新的方法節點,其名稱為提供的名稱,並定義此方法的回傳類型。此外,類型檢查器會在 generatedMethods 清單中新增這些方法(請參閱下方的 isGenerated)。我們僅設定名稱和回傳類型的原因是,在 90% 的情況下,這正是您所需要的。例如,在上面的 findByName 範例中,您唯一需要知道的是 findByName 在執行階段不會失敗,而且它會回傳一個網域類別。回傳類型的 Callable 版本很有趣,因為它會在類型檢查器實際需要時延後計算回傳類型。這很有趣,因為在某些情況下,您可能在類型檢查器要求時不知道實際回傳類型,因此您可以使用一個封閉函式,每次類型檢查器針對此方法節點呼叫 getReturnType 時,就會呼叫這個封閉函式。如果您將此與延後檢查結合使用,您可以達成相當複雜的類型檢查,包括處理向前參考。

newMethod(name) {
    // each time getReturnType on this method node will be called, this closure will be called!
    println 'Type checker called me!'
    lookupClassNodeFor(Foo) // return type
}

如果您需要的不僅僅是名稱和回傳類型,您隨時可以自行建立一個新的 MethodNode

範圍

範圍在 DSL 類型檢查中非常重要,也是我們無法使用基於切入點的方法進行 DSL 類型檢查的原因之一。基本上,您必須能夠非常精確地定義擴充套件何時套用以及何時不套用。此外,您必須能夠處理一般類型檢查器無法處理的情況,例如向前參考

point a(1,1)
line a,b // b is referenced afterwards!
point b(5,2)

例如,假設您想要處理一個建構器

builder.foo {
   bar
   baz(bar)
}

那麼,您的擴充套件應該只在您進入foo方法後才會啟用,而在此範圍之外則會停用。但是,您可能會遇到複雜的情況,例如同一個檔案中有多個建構器或嵌入式建構器(建構器中的建構器)。雖然您不應該從一開始就嘗試修復所有這些問題(您必須接受類型檢查的限制),但類型檢查器確實提供了一個很好的機制來處理這個問題:範圍堆疊,使用newScopescopeExit方法。

  • newScope建立一個新的範圍並將其放在堆疊的頂端

  • scopeExits從堆疊中彈出一個範圍

範圍包含

  • 父範圍

  • 自訂資料的對應

如果您想查看實作,它只是一個LinkedHashMap (org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport.TypeCheckingScope),但功能非常強大。例如,您可以使用這樣的範圍來儲存離開範圍時要執行的封閉式清單。以下是您處理向前參考的方式: 

def scope = newScope()
scope.secondPassChecks = []
//...
scope.secondPassChecks << { println 'executed later' }
// ...
scopeExit {
    secondPassChecks*.run() // execute deferred checks
}

也就是說,如果您在某個時間點無法確定表達式的類型,或者您無法在這個時間點檢查指派是否有效,您仍然可以在稍後進行檢查… 這是一個非常強大的功能。現在,newScopescopeExit提供了一些有趣的語法糖

newScope {
    secondPassChecks = []
}

在 DSL 中的任何時間,您都可以使用getCurrentScope()或更簡單的currentScope來存取目前的範圍

//...
currentScope.secondPassChecks << { println 'executed later' }
// ...

一般的架構會是

  • 確定一個切入點,您可以在其中將新的範圍推送到堆疊中,並在此範圍內初始化自訂變數

  • 使用各種事件,您可以使用儲存在自訂範圍中的資訊來執行檢查、延後檢查…

  • 決定您離開範圍的切入點,呼叫 scopeExit 並最終執行其他檢查

其他有用的方法

有關輔助方法的完整清單,請參閱 org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupportorg.codehaus.groovy.transform.stc.TypeCheckingExtension 類別。但是,特別注意這些方法

  • isDynamic:將 VariableExpression 作為引數,如果變數是 DynamicExpression,則傳回 true,這表示在指令碼中,它並未使用類型或 def 定義。

  • isGenerated:將 MethodNode 作為引數,並說明方法是否是由類型檢查器擴充功能使用 newMethod 方法產生的方法

  • isAnnotatedBy:將 AST 節點和類別 (或 ClassNode) 作為引數,並說明節點是否使用此類別註解。例如:isAnnotatedBy(node, NotNull)

  • getTargetMethod:將方法呼叫作為引數,並傳回類型檢查器已為其決定的 MethodNode

  • delegatesTo:模擬 @DelegatesTo 註解的行為。它讓您可以說明引數將委派給特定類型 (您也可以指定委派策略)

7.2. 進階類型檢查擴充功能

7.2.1. 預編譯類型檢查擴充功能

以上所有範例都使用類型檢查指令碼。它們以原始碼形式出現在類別路徑中,表示

  • 與類型檢查擴充功能對應的 Groovy 原始碼檔案可於編譯類別路徑中取得

  • 此檔案由 Groovy 編譯器為每個正在編譯的原始碼單元編譯 (通常,原始碼單元對應到單一檔案)

這是開發類型檢查擴充功能非常方便的方式,但是它表示編譯階段會較慢,因為會為每個正在編譯的檔案編譯擴充功能本身。由於這些原因,依賴預編譯擴充功能可能是實用的。您有兩個選項可以執行此動作

  • 以 Groovy 編寫擴充功能,編譯它,然後使用擴充功能類別的參考,而不是原始碼

  • 以 Java 編寫擴充功能,編譯它,然後使用擴充功能類別的參考

以 Groovy 編寫類型檢查擴充功能是最簡單的路徑。基本上,構想是類型檢查擴充功能指令碼會成為類型檢查擴充功能類別的主要方法主體,如下所示

import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport

class PrecompiledExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {     (1)
    @Override
    Object run() {                                                                          (2)
        unresolvedVariable { var ->
            if ('robot'==var.name) {
                storeType(var, classNodeFor(Robot))                                         (3)
                handled = true
            }
        }
    }
}
1 擴充 TypeCheckingDSL 類別是最簡單的
2 然後擴充功能程式碼需要放入 run 方法中
3 而且您可以使用與以原始碼形式編寫的擴充功能完全相同的事件

設定擴充功能與使用原始碼形式擴充功能非常類似

config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        TypeChecked,
        extensions:['typing.PrecompiledExtension'])
)

不同之處在於,您不必在類別路徑中使用路徑,只需指定預編譯擴充功能的完全限定類別名稱。

如果您真的想用 Java 編寫擴充功能,那麼您將無法從類型檢查擴充功能 DSL 中受益。上述擴充功能可以用 Java 這樣改寫

import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.transform.stc.AbstractTypeCheckingExtension;


import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;

public class PrecompiledJavaExtension extends AbstractTypeCheckingExtension {                   (1)

    public PrecompiledJavaExtension(final StaticTypeCheckingVisitor typeCheckingVisitor) {
        super(typeCheckingVisitor);
    }

    @Override
    public boolean handleUnresolvedVariableExpression(final VariableExpression vexp) {          (2)
        if ("robot".equals(vexp.getName())) {
            storeType(vexp, ClassHelper.make(Robot.class));
            setHandled(true);
            return true;
        }
        return false;
    }

}
1 延伸 AbstractTypeCheckingExtension 類別
2 然後視需要覆寫 handleXXX 方法

7.2.2. 在類型檢查擴充功能中使用 @Grab

在類型檢查擴充功能中使用 @Grab 註解完全可行。這表示您可以包含僅在編譯時才可用的函式庫。在這種情況下,您必須了解這樣會大幅增加編譯時間(至少第一次擷取相依項時會增加)。

7.2.3. 共享或封裝類型檢查擴充功能

類型檢查擴充功能只是一個需要在類別路徑上的腳本。因此,您可以按原樣分享它,或將它打包在會新增到類別路徑的 jar 檔案中。

7.2.4. 全域類型檢查擴充功能

雖然您可以設定編譯器以透明的方式將類型檢查擴充功能新增到您的腳本,但目前沒有辦法透過讓擴充功能在類別路徑上就透明地套用它。

7.2.5. 類型檢查擴充功能和 @CompileStatic

類型檢查擴充功能與 @TypeChecked 一起使用,但也可以與 @CompileStatic 一起使用。不過,您必須知道

  • @CompileStatic 一起使用的類型檢查擴充功能通常不足以讓編譯器知道如何從「不安全」的程式碼產生靜態可編譯的程式碼

  • 可以使用類型檢查擴充功能與 @CompileStatic 一起使用,只是為了加強類型檢查,也就是說引入更多編譯錯誤,而實際上並未處理動態程式碼

讓我們說明第一點,即使您使用擴充套件,編譯器也不會知道如何靜態編譯您的程式碼:技術上來說,即使您告訴類型檢查器動態變數的類型是什麼,例如,它也不會知道如何編譯它。它是 getBinding('foo')getProperty('foo')delegate.getFoo(),…嗎?即使您使用類型檢查擴充套件(這將再次僅提供關於類型的提示),也沒有任何直接的方法告訴靜態編譯器如何編譯此類程式碼。

針對此特定範例,一個可能的解決方案是指示編譯器使用 混合模式編譯。更進階的做法是在類型檢查期間使用 AST 轉換作為擴充套件,但這複雜得多。

類型檢查擴充套件讓您可以在類型檢查器失敗時提供協助,但它也允許您在類型檢查器未失敗時失敗。在這種情況下,支援 @CompileStatic 的擴充套件是有意義的。想像一個能夠類型檢查 SQL 查詢的擴充套件。在這種情況下,擴充套件在動態和靜態的內容中都是有效的,因為沒有擴充套件,程式碼仍然會通過。

7.2.6. 混合模式編譯

在前一節中,我們強調了您可以使用 @CompileStatic 啟用類型檢查擴充套件。在這種情況下,類型檢查器將不再抱怨某些未解析的變數或未知的方法呼叫,但它仍然不知道如何靜態編譯它們。

混合模式編譯提供第三種方式,即指示編譯器每當找到未解析的變數或方法呼叫時,它應回退到動態模式。這要歸功於類型檢查擴充套件和特殊的 makeDynamic 呼叫。

為了說明這一點,讓我們回到 Robot 範例

robot.move 100

讓我們嘗試使用 @CompileStatic 而不是 @TypeChecked 來啟用我們的類型檢查擴充套件

def config = new CompilerConfiguration()
config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        CompileStatic,                                      (1)
        extensions:['robotextension.groovy'])               (2)
)
def shell = new GroovyShell(config)
def robot = new Robot()
shell.setVariable('robot', robot)
shell.evaluate(script)
1 透明地套用 @CompileStatic
2 啟用類型檢查擴充套件

腳本會執行良好,因為靜態編譯器已得知 robot 變數的類型,因此它能夠直接呼叫 move。但在這之前,編譯器是如何得知取得 robot 變數的?事實上,預設情況下,在類型檢查擴充中,對未解析變數設定 handled=true 會自動觸發動態解析,因此在這種情況下,您無需執行任何特殊操作即可讓編譯器使用混合模式。不過,讓我們從機器人腳本開始,稍微更新我們的範例

move 100

您會注意到這裡不再參照 robot。我們的擴充將無法提供協助,因為我們無法指示編譯器在 Robot 執行個體上執行 move。此程式碼範例可以在 groovy.util.DelegatingScript 的協助下以完全動態的方式執行

def config = new CompilerConfiguration()
config.scriptBaseClass = 'groovy.util.DelegatingScript'     (1)
def shell = new GroovyShell(config)
def runner = shell.parse(script)                            (2)
runner.setDelegate(new Robot())                             (3)
runner.run()                                                (4)
1 我們將編譯器設定為使用 DelegatingScript 作為基底類別
2 腳本來源需要進行剖析,並會傳回 DelegatingScript 的執行個體
3 然後我們可以呼叫 setDelegate 以使用 Robot 作為腳本的委派
4 然後執行腳本。move 會直接在委派上執行

如果我們希望使用 @CompileStatic 通過此腳本,我們必須使用類型檢查擴充,因此讓我們更新我們的設定

config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        CompileStatic,                                      (1)
        extensions:['robotextension2.groovy'])              (2)
)
1 透明地套用 @CompileStatic
2 使用旨在辨識對 move 的呼叫的替代類型檢查擴充

然後在上一節中,我們已學習如何處理無法辨識的方法呼叫,因此我們可以撰寫此擴充

robotextension2.groovy
methodNotFound { receiver, name, argList, argTypes, call ->
    if (isMethodCallExpression(call)                        (1)
        && call.implicitThis                                (2)
        && 'move'==name                                     (3)
        && argTypes.length==1                               (4)
        && argTypes[0] == classNodeFor(int)                 (5)
    ) {
        handled = true                                      (6)
        newMethod('move', classNodeFor(Robot))              (7)
    }
}
1 如果呼叫是方法呼叫(而非靜態方法呼叫)
2 此呼叫是在「隱含 this」上進行的(無明確的 this.
3 正在呼叫的方法是 move
4 且呼叫是使用單一引數進行的
5 且該引數的類型為 int
6 然後告知類型檢查器呼叫有效
7 且呼叫的傳回類型為 Robot

如果您嘗試執行此程式碼,您可能會驚訝地發現它實際上在執行時期失敗

java.lang.NoSuchMethodError: java.lang.Object.move()Ltyping/Robot;

原因很簡單:雖然類型檢查擴充套件足以應付 @TypeChecked,它不涉及靜態編譯,但對於需要額外資訊的 @CompileStatic 來說則不足夠。在這種情況下,你告訴編譯器方法存在,但你沒有向它解釋它實際上是什麼方法,以及訊息的接收者(委派)是什麼。

修復這一點非常容易,只需用其他東西取代 newMethod 呼叫即可

robotextension3.groovy
methodNotFound { receiver, name, argList, argTypes, call ->
    if (isMethodCallExpression(call)
        && call.implicitThis
        && 'move'==name
        && argTypes.length==1
        && argTypes[0] == classNodeFor(int)
    ) {
        makeDynamic(call, classNodeFor(Robot))              (1)
    }
}
1 告訴編譯器呼叫應為動態呼叫

makeDynamic 呼叫會執行 3 件事

  • 它會傳回一個虛擬方法,就像 newMethod 一樣

  • 自動為你將 handled 旗標設定為 true

  • 但也會標記 call 為動態執行

因此,當編譯器必須為對 move 的呼叫產生位元組碼時,由於它現在標記為動態呼叫,它會回退到動態編譯器並讓它處理呼叫。由於擴充套件告訴我們動態呼叫的傳回類型是 Robot,後續呼叫將會以靜態方式執行!

有些人會疑惑為什麼靜態編譯器不會在沒有擴充套件的情況下預設執行此操作。這是一個設計決策

  • 如果程式碼是靜態編譯的,我們通常需要類型安全性與最佳效能

  • 因此,如果將無法辨識的變數/方法呼叫設為動態,你會失去類型安全性,也會失去編譯時的所有拼寫錯誤支援!

簡而言之,如果你想要有混合模式編譯,它必須透過類型檢查擴充套件明確表示,以便編譯器和 DSL 設計者完全知道自己在做什麼。

makeDynamic 可用於 3 種 AST 節點

  • 方法節點 (MethodNode)

  • 變數 (VariableExpression)

  • 屬性表達式 (PropertyExpression)

如果這樣還不夠,表示無法直接執行靜態編譯,你必須依賴 AST 轉換。

7.2.7. 在擴充套件中轉換 AST

類型檢查擴充功能從 AST 轉換設計觀點來看非常有吸引力:擴充功能可以存取推論類型等內容,這通常會很好用。而且擴充功能可以直接存取抽象語法樹。由於您可以存取 AST,理論上沒有任何事情可以阻止您修改 AST。但是,除非您是進階 AST 轉換設計人員,並且非常了解編譯器內部結構,否則我們不建議您這麼做

  • 首先,您會明確地違反類型檢查的合約,也就是註解,而且只註解 AST。類型檢查不應修改 AST 樹,因為您將無法再保證沒有 @TypeChecked 註解的程式碼在沒有註解的情況下表現相同。

  • 如果您的擴充功能旨在與 @CompileStatic 搭配使用,那麼您可以修改 AST,因為這的確是 @CompileStatic 最終會執行的動作。靜態編譯無法保證與動態 Groovy 相同的語意,因此使用 @CompileStatic 編譯的程式碼與使用 @TypeChecked 編譯的程式碼之間確實存在差異。您可以選擇任何策略來更新 AST,但可能使用在類型檢查之前執行的 AST 轉換會比較容易。

  • 如果您無法依賴在類型檢查器之前啟動的轉換,那麼您必須非常小心

類型檢查階段是位元組碼產生之前在編譯器中執行的最後一個階段。所有其他 AST 轉換都會在該階段之前執行,而且編譯器在「修正」類型檢查階段之前產生的不正確 AST 方面做得非常好。只要您在類型檢查期間執行轉換,例如直接在類型檢查擴充功能中執行,那麼您就必須自己完成所有這項工作,以產生 100% 相容於編譯器的抽象語法樹,這可能會變得複雜。這就是為什麼如果您是從類型檢查擴充功能和 AST 轉換開始,我們不建議您這樣做的原因。

7.2.8. 範例

很容易找到實際生活中的類型檢查擴充功能範例。您可以下載 Groovy 的原始碼,並查看連結到 TypeCheckingExtensionsTest 類別的 各種擴充功能指令碼

可以在 標記範本引擎 原始碼中找到複雜類型檢查擴充功能的範例:此範本引擎依賴類型檢查擴充功能和 AST 轉換,將範本轉換為完全靜態編譯的程式碼。可以 在此處 找到此原始碼。