物件導向

本章節涵蓋 Groovy 程式語言的物件導向面向。

1. 型別

1.1. 基本型別

Groovy 支援與 Java 語言規範 定義相同的基本型別

  • 整數型別:byte(8 位元)、short(16 位元)、int(32 位元)和 long(64 位元)

  • 浮點型別:float(32 位元)和 double(64 位元)

  • boolean 型別(truefalse 之一)

  • char 型別(16 位元,可用作數值型別,代表 UTF-16 碼)

與 Java 相同,Groovy 在需要與任何基本型別對應的物件時,也會使用各自的包裝類別

表 1. 基本包裝類別
基本型別 包裝類別

boolean

Boolean

char

Character

short

Short

int

Integer

long

Long

float

Float

double

Double

當呼叫需要包裝類別的方法並將基本變數作為參數傳遞,或反之亦然時,就會發生自動裝箱和拆箱。這與 Java 類似,但 Groovy 將此概念更進一步。

在大多數情況下,您可以將基本型別視為與完整的物件包裝類別等效。例如,您可以在基本型別上呼叫 .toString().equals(other)。Groovy 會根據需要在參考和基本型別之間自動包裝和拆箱。

以下是一個使用 int 的範例,它被宣告為類別中的靜態欄位(稍後討論)

class Foo {
    static int i
}

assert Foo.class.getDeclaredField('i').type == int.class           (1)
assert Foo.i.class != int.class && Foo.i.class == Integer.class    (2)
1 基本型別在位元組碼中受到尊重
2 在執行階段查看欄位,會發現它已自動包裝

現在您可能會擔心,這表示每次在基本型別的參考上使用數學運算子時,您都會承擔拆箱和重新包裝基本型別的成本。但事實並非如此,因為 Groovy 會將您的運算子編譯成它們的 方法等效項,並改用它們。此外,當呼叫採用基本參數的 Java 方法時,Groovy 會自動拆箱為基本型別,並自動將 Java 的基本方法傳回值裝箱。但是,請注意,與 Java 的方法解析有一些 差異

1.2. 參考型別

除了基本類型之外,其他所有內容都是物件,並具有定義其類型的關聯類別。我們將在稍後討論類別,以及與類別相關或類別相似的項目,例如介面、特徵和記錄。

我們可以宣告兩個變數,類型分別為字串和清單,如下所示

String movie = 'The Matrix'
List actors = ['Keanu Reeves', 'Hugo Weaving']

1.3. 泛型

Groovy 在泛型方面承襲了 Java 的相同概念。在定義類別和方法時,可以使用類型參數並建立泛型類別、介面、方法或建構函式。

使用泛型類別和方法,無論它們是在 Java 或 Groovy 中定義,都可能涉及提供類型參數。

我們可以宣告一個變數,類型為「字串清單」,如下所示

List<String> roles = ['Trinity', 'Morpheus']

Java 使用類型擦除,以與早期版本的 Java 向後相容。動態 Groovy 可以被視為更積極地應用類型擦除。一般而言,在編譯時會檢查較少的泛型類型資訊。Groovy 的靜態特性在泛型資訊方面採用與 Java 類似的檢查。

2. 類別

Groovy 類別與 Java 類別非常相似,且在 JVM 層級與 Java 類別相容。它們可能具有方法、欄位和屬性(想想 JavaBeans 屬性,但樣板較少)。類別和類別成員可以具有與 Java 相同的修飾詞(public、protected、private、static 等),但在原始碼層級有一些細微的差異,我們將在稍後說明。

Groovy 類別與其 Java 對應類別之間的主要差異為

  • 沒有可見性修飾詞的類別或方法會自動設為 public(可以使用特殊註解來達成封裝私有可見性)。

  • 沒有可見性修飾詞的欄位會自動轉換為屬性,這會產生較不冗長的程式碼,因為不需要明確的 getter 和 setter 方法。我們將在欄位和屬性區段中進一步介紹這個面向。

  • 類別不需要與其原始碼檔案定義具有相同的基本名稱,但在大多數情況下都強烈建議這麼做(另請參閱下一個關於腳本的重點)。

  • 一個原始碼檔案可以包含一個或多個類別(但如果檔案包含任何不在類別中的程式碼,則會被視為腳本)。腳本只是具有某些特殊慣例的類別,且會與其原始碼檔案具有相同的名稱(因此請勿在與腳本原始碼檔案同名的腳本中包含類別定義)。

以下程式碼提供一個類別範例。

class Person {                       (1)

    String name                      (2)
    Integer age

    def increaseAge(Integer years) { (3)
        this.age += years
    }
}
1 類別開頭,名稱為 Person
2 字串欄位和屬性,名稱為 name
3 方法定義

2.1. 一般類別

一般類別是指頂層且具體的類別。這表示它們可以從任何其他類別或腳本中進行實體化,而沒有限制。這樣,它們只能是公開的(即使 public 關鍵字可能會被抑制)。類別是透過呼叫其建構函式來實體化,使用 new 關鍵字,如下面的程式碼片段所示。

def p = new Person()

2.2. 內部類別

內部類別是在另一個類別中定義的。封裝類別可以照常使用內部類別。另一方面,內部類別可以存取其封裝類別的成員,即使它們是私有的。封裝類別以外的類別不被允許存取內部類別。以下是一個範例

class Outer {
    private String privateStr

    def callInnerMethod() {
        new Inner().methodA()       (1)
    }

    class Inner {                   (2)
        def methodA() {
            println "${privateStr}." (3)
        }
    }
}
1 內部類別被實體化,並且呼叫其方法
2 內部類別定義,在其封裝類別中
3 即使是私有的,封裝類別的欄位也可以被內部類別存取

使用內部類別有一些原因

  • 它們透過隱藏不需要知道它的其他類別中的內部類別來增加封裝。這也導致更簡潔的套件和工作空間。

  • 它們透過將僅由一個類別使用的類別分組,提供良好的組織。

  • 它們導致更易於維護的程式碼,因為內部類別靠近使用它們的類別。

內部類別通常是某個介面的實作,其方法是由外部類別所需要的。以下程式碼說明了這個典型的使用模式,這裡與執行緒一起使用。

class Outer2 {
    private String privateStr = 'some string'

    def startThread() {
       new Thread(new Inner2()).start()
    }

    class Inner2 implements Runnable {
        void run() {
            println "${privateStr}."
        }
    }
}

請注意,類別 Inner2 僅定義為提供方法 run 給類別 Outer2 的實作。匿名內部類別有助於消除這種情況下的冗餘。該主題將在稍後討論。

Groovy 3+ 也支援非靜態內部類別實體化的 Java 語法,例如

class Computer {
    class Cpu {
        int coreNumber

        Cpu(int coreNumber) {
            this.coreNumber = coreNumber
        }
    }
}

assert 4 == new Computer().new Cpu(4).coreNumber

2.2.1. 匿名內部類別

內部類別(Inner2)的先前範例可以用匿名內部類別簡化。相同的功能可以用下列程式碼達成

class Outer3 {
    private String privateStr = 'some string'

    def startThread() {
        new Thread(new Runnable() {      (1)
            void run() {
                println "${privateStr}."
            }
        }).start()                       (2)
    }
}
1 與前一節的最後一個範例比較,new Inner2() 已被 new Runnable() 取代,連同其所有實作
2 方法 start 會正常呼叫

因此,不需要定義一個只會使用一次的新類別。

2.2.2. 抽象類別

抽象類別代表一般概念,因此無法實例化,而是建立為子類別。它們的成員包括欄位/屬性和抽象或具體方法。抽象方法沒有實作,而且必須由具體子類別實作。

abstract class Abstract {         (1)
    String name

    abstract def abstractMethod() (2)

    def concreteMethod() {
        println 'concrete'
    }
}
1 抽象類別必須使用 abstract 關鍵字宣告
2 抽象方法也必須使用 abstract 關鍵字宣告

抽象類別通常與介面比較。選擇其中一個至少有兩個重要的差異。首先,抽象類別可以包含欄位/屬性和具體方法,而介面只能包含抽象方法(方法簽章)。此外,一個類別可以實作多個介面,但只能延伸一個類別,無論是否為抽象類別。

2.3. 繼承

Groovy 中的繼承類似於 Java 中的繼承。它提供一個機制,讓子類別(或子類)可以重複使用父類別(或超類別)的程式碼或屬性。透過繼承關聯的類別會形成一個繼承層級。常見的行為和成員會往上推到層級中,以減少重複。特例會出現在子類別中。

支援不同的繼承形式

  • 實作繼承,其中 超類別 或一個或多個 特徵 的程式碼(方法、欄位或屬性)會被子類別重複使用

  • 合約繼承,其中一個類別承諾提供 超類別 中定義的特定抽象方法,或在一個或多個 特徵介面 中定義的方法。

2.4. 超類別

父類別與子類別共用可見欄位、屬性或方法。一個子類別最多只能有一個父類別。extends 關鍵字會在給出超類別類型之前立即使用。

2.5. 介面

介面定義了一個類別需要符合的合約。介面只定義需要實作的方法清單,但不會定義方法的實作。

interface Greeter {                                         (1)
    void greet(String name)                                 (2)
}
1 介面需要使用 interface 關鍵字宣告
2 介面只定義方法簽章

介面的方法總是 public。在介面中使用 protectedprivate 方法是錯誤的

interface Greeter {
    protected void greet(String name)           (1)
}
1 使用 protected 是編譯時期錯誤

如果類別在 implements 清單中定義介面,或任何超類別有定義,則類別會實作介面

class SystemGreeter implements Greeter {                    (1)
    void greet(String name) {                               (2)
        println "Hello $name"
    }
}

def greeter = new SystemGreeter()
assert greeter instanceof Greeter                           (3)
1 SystemGreeter 使用 implements 關鍵字宣告 Greeter 介面
2 然後實作必要的 greet 方法
3 SystemGreeter 的任何實例也是 Greeter 介面的實例

介面可以延伸另一個介面

interface ExtendedGreeter extends Greeter {                 (1)
    void sayBye(String name)
}
1 ExtendedGreeter 介面使用 extends 關鍵字延伸 Greeter 介面

值得注意的是,類別要成為介面的實例,必須明確指定。例如,以下類別定義 greet 方法,如同在 Greeter 介面中宣告,但沒有在介面中宣告 Greeter

class DefaultGreeter {
    void greet(String name) { println "Hello" }
}

greeter = new DefaultGreeter()
assert !(greeter instanceof Greeter)

換句話說,Groovy 沒有定義結構化類型。不過,可以使用 as 轉換運算子在執行時期讓物件實例實作介面

greeter = new DefaultGreeter()                              (1)
coerced = greeter as Greeter                                (2)
assert coerced instanceof Greeter                           (3)
1 建立不實作介面的 DefaultGreeter 實例
2 在執行時期將實例轉換為 Greeter
3 轉換後的實例實作 Greeter 介面

你可以看到有兩個不同的物件:一個是來源物件,一個不實作介面的 DefaultGreeter 實例。另一個是 Greeter 的實例,委派給轉換後的物件。

Groovy 介面不支援 Java 8 介面的預設實作。如果你正在尋找類似(但不等同)的東西,特質 接近介面,但允許預設實作以及本手冊中描述的其他重要功能。

3. 類別成員

3.1. 建構函式

建構函式是一種特殊的方法,用於初始化具有特定狀態的物件。與一般方法一樣,類別可以宣告多個建構函式,只要每個建構函式都有唯一的型別簽章。如果物件在建構期間不需要任何參數,它可以使用無參數建構函式。如果沒有提供任何建構函式,Groovy 編譯器會提供一個空的無參數建構函式。

Groovy 支援兩種呼叫樣式

  • 位置參數的使用方式類似於 Java 建構函式的使用方式

  • 命名參數允許您在呼叫建構函式時指定參數名稱。

3.1.1. 位置參數

要使用位置參數建立物件,類別需要宣告一個或多個建構函式。如果有多個建構函式,每個建構函式都必須有唯一的型別簽章。也可以使用 groovy.transform.TupleConstructor 注解將建構函式新增到類別中。

通常,一旦宣告至少一個建構函式,類別只能透過呼叫其中一個建構函式來建立實例。值得注意的是,在這種情況下,您通常無法使用命名參數建立類別。只要類別包含無參數建構函式或提供一個將 Map 參數作為第一個(且可能是唯一)參數的建構函式,Groovy 就支援命名參數 - 請參閱下一節的詳細資訊。

使用宣告的建構函式有三個形式。第一個是使用 new 關鍵字的正常 Java 方式。其他方式依賴於將清單強制轉換為所需的型別。在這種情況下,可以使用 as 關鍵字和透過靜態輸入變數來強制轉換。

class PersonConstructor {
    String name
    Integer age

    PersonConstructor(name, age) {          (1)
        this.name = name
        this.age = age
    }
}

def person1 = new PersonConstructor('Marie', 1)  (2)
def person2 = ['Marie', 2] as PersonConstructor  (3)
PersonConstructor person3 = ['Marie', 3]         (4)
1 建構函式宣告
2 建構函式呼叫,經典 Java 方式
3 建構函式使用,使用 as 關鍵字強制轉換
4 建構函式使用,在指定中強制轉換

3.1.2. 命名參數

如果沒有宣告建構函式(或無參數建構函式),則可以透過傳遞以映射(屬性/值對)形式的參數來建立物件。這在希望允許多種參數組合的情況下很方便。否則,透過使用傳統的位置參數,有必要宣告所有可能的建構函式。它也支援建構函式,其中第一個(且可能是唯一)參數是 Map 參數 - 也可以使用 groovy.transform.MapConstructor 注解新增此類建構函式。

class PersonWOConstructor {                                  (1)
    String name
    Integer age
}

def person4 = new PersonWOConstructor()                      (2)
def person5 = new PersonWOConstructor(name: 'Marie')         (3)
def person6 = new PersonWOConstructor(age: 1)                (4)
def person7 = new PersonWOConstructor(name: 'Marie', age: 2) (5)
1 未宣告建構函式
2 在建立實例時未提供參數
3 在建立實例時提供 name 參數
4 在實例化中給定的age參數
5 在實例化中給定的nameage參數

然而,重要的是要強調,這種方法賦予建構函式呼叫者更多權力,同時也對呼叫者施加了更大的責任,以正確取得名稱和值類型。因此,如果需要更大的控制權,則可能偏好使用位置參數宣告建構函式。

備註

  • 雖然上述範例未提供建構函式,但您也可以提供無參數建構函式或第一個參數為Map的建構函式,最典型的是它唯一參數。

  • 當未宣告建構函式(或無參數建構函式)時,Groovy 會以呼叫無參數建構函式取代命名建構函式呼叫,然後再呼叫每個提供命名屬性的設定器。

  • 當第一個參數為 Map 時,Groovy 會將所有命名參數組合成 Map(不論順序),並將該 Map 提供為第一個參數。如果您的屬性宣告為final,這會是一個好方法(因為它們會在建構函式中設定,而不是在事實上使用設定器設定)。

  • 您可以同時提供位置建構函式和無參數或 Map 建構函式,以支援命名和位置建構。

  • 您可以透過擁有第一個參數為 Map 但也有其他位置參數的建構函式,來支援混合建構。請謹慎使用此樣式。

3.2. 方法

Groovy 方法與其他語言非常相似。一些特殊性將在下一小節中顯示。

3.2.1. 方法定義

方法使用回傳類型或def關鍵字定義,以使回傳類型無類型。方法也可以接收任意數量的參數,這些參數可能沒有明確宣告其類型。Java 修飾詞可以正常使用,如果未提供可見性修飾詞,則方法為 public。

Groovy 中的方法總是會回傳一些值。如果未提供return陳述式,則會回傳在執行最後一行時評估的值。例如,請注意以下方法均未使用return關鍵字。

def someMethod() { 'method called' }                           (1)
String anotherMethod() { 'another method called' }             (2)
def thirdMethod(param1) { "$param1 passed" }                   (3)
static String fourthMethod(String param1) { "$param1 passed" } (4)
1 未宣告回傳類型且無參數的方法
2 具有明確回傳類型且無參數的方法
3 具有未定義類型參數的方法
4 具有字串參數的靜態方法

3.2.2. 命名參數

與建構函式一樣,也可以使用命名參數呼叫一般方法。為了支援這種表示法,使用一種慣例,其中方法的第一個參數是Map。在方法主體中,可以像在一般 Map 中一樣存取參數值(map.key)。如果方法只有一個 Map 參數,則所有提供的參數都必須命名。

def foo(Map args) { "${args.name}: ${args.age}" }
foo(name: 'Marie', age: 1)
混合命名和位置參數

命名參數可以與位置參數混合。在這種情況下,除了第一個參數為 Map 參數外,適用的慣例是,該方法將根據需要有額外的定位參數。呼叫方法時提供的定位參數必須按順序。命名參數可以出現在任何位置。它們會分組到映射中,並自動提供為第一個參數。

def foo(Map args, Integer number) { "${args.name}: ${args.age}, and the number is ${number}" }
foo(name: 'Marie', age: 1, 23)  (1)
foo(23, name: 'Marie', age: 1)  (2)
1 使用額外的 number 參數 (Integer 類型) 的方法呼叫
2 參數順序已變更的方法呼叫

如果我們沒有將 Map 作為第一個參數,則必須提供一個 Map,以取代命名參數作為該參數。否則,將導致 groovy.lang.MissingMethodException

def foo(Integer number, Map args) { "${args.name}: ${args.age}, and the number is ${number}" }
foo(name: 'Marie', age: 1, 23)  (1)
1 方法呼叫會擲回 groovy.lang.MissingMethodException: No signature of method: foo() is applicable for argument types: (LinkedHashMap, Integer) values: [[name:Marie, age:1], 23],因為命名參數 Map 參數未定義為第一個參數

如果我們用明確的 Map 參數取代命名參數,就可以避免上述例外狀況

def foo(Integer number, Map args) { "${args.name}: ${args.age}, and the number is ${number}" }
foo(23, [name: 'Marie', age: 1])  (1)
1 取代命名參數的明確 Map 參數使呼叫有效
儘管 Groovy 允許您混合命名和位置參數,但這可能會造成不必要的混淆。請謹慎混合命名和位置參數。

3.2.3. 預設參數

預設參數使參數成為選用參數。如果未提供參數,則方法會假設預設值。

def foo(String par1, Integer par2 = 1) { [name: par1, age: par2] }
assert foo('Marie').age == 1

參數會從右邊刪除,但強制參數絕不會被刪除。

def baz(a = 'a', int b, c = 'c', boolean d, e = 'e') { "$a $b $c $d $e" }

assert baz(42, true) == 'a 42 c true e'
assert baz('A', 42, true) == 'A 42 c true e'
assert baz('A', 42, 'C', true) == 'A 42 C true e'
assert baz('A', 42, 'C', true, 'E') == 'A 42 C true E'

相同的規則適用於建構函式和方法。如果使用 @TupleConstructor,則會套用額外的設定選項。

3.2.4. 可變引數

Groovy 支援具有可變數目參數的方法。這些方法定義如下:def foo(p1, …​, pn, T…​ args)。在此,foo 預設支援 n 個參數,但也可以支援超過 n 個未指定數目的其他參數。

def foo(Object... args) { args.length }
assert foo() == 0
assert foo(1) == 1
assert foo(1, 2) == 2

這個範例定義了一個方法 foo,它可以接受任意數量的引數,包括完全沒有引數。args.length 會傳回所給引數的數量。Groovy 允許 T[] 作為 T…​ 的替代表示法。這表示任何以陣列作為最後一個參數的方法,Groovy 都會視為一個可以接受可變數量引數的方法。

def foo(Object[] args) { args.length }
assert foo() == 0
assert foo(1) == 1
assert foo(1, 2) == 2

如果一個具有可變引數的方法以 null 作為可變引數參數呼叫,則引數將會是 null,而不是長度為 1 的陣列,其中唯一元素為 null

def foo(Object... args) { args }
assert foo(null) == null

如果一個具有可變引數的方法以陣列作為引數呼叫,則引數將會是該陣列,而不是長度為 1 的陣列,其中唯一元素包含給定的陣列。

def foo(Object... args) { args }
Integer[] ints = [1, 2]
assert foo(ints) == [1, 2]

另一個重點是可變引數與方法重載的組合。在方法重載的情況下,Groovy 會選取最明確的方法。例如,如果一個方法 foo 接受類型為 T 的可變引數,另一個方法 foo 也接受一個類型為 T 的引數,則會優先選取第二個方法。

def foo(Object... args) { 1 }
def foo(Object x) { 2 }
assert foo() == 1
assert foo(1) == 2
assert foo(1, 2) == 1

3.2.5. 方法選取演算法

動態 Groovy 支援 多重傳遞(又稱多重方法)。呼叫一個方法時,實際呼叫的方法會根據方法引數的執行時期類型動態地決定。首先會考慮方法名稱和引數數量(包括允許可變引數),然後考慮每個引數的類型。考慮以下方法定義

def method(Object o1, Object o2) { 'o/o' }
def method(Integer i, String  s) { 'i/s' }
def method(String  s, Integer i) { 's/i' }

或許如預期,以 StringInteger 參數呼叫 method 會呼叫我們的第三個方法定義。

assert method('foo', 42) == 's/i'

更有趣的是,當編譯時類型未知時。參數可能宣告為 Object 類型(在我們的案例中,是此類物件的清單)。Java 會判定在所有案例中,都會選用 method(Object, Object) 變體(除非使用強制轉型),但正如以下範例所示,Groovy 會使用執行時期類型,並會呼叫我們的每個方法一次(而且通常不需要強制轉型)

List<List<Object>> pairs = [['foo', 1], [2, 'bar'], [3, 4]]
assert pairs.collect { a, b -> method(a, b) } == ['s/i', 'i/s', 'o/o']

對於我們三個方法呼叫中的前兩個,都找到了參數類型的完全符合。對於第三個呼叫,並未找到 method(Integer, Integer) 的完全符合,但 method(Object, Object) 仍然有效,且會被選用。

因此,方法選取是從具有相容參數類型的有效方法候選項中,找出最接近的符合。因此,method(Object, Object) 對前兩個呼叫也是有效的,但不如類型完全符合的變體那麼接近。為了判定最接近的符合,執行時期會對實際參數類型與宣告參數類型之間的距離有所概念,並試圖將所有參數的總距離最小化。

下表說明了影響距離計算的一些因素。

面向 範例

直接實作的介面比從繼承階層更上層的介面更接近符合。

給定這些介面和方法定義

interface I1 {}
interface I2 extends I1 {}
interface I3 {}
class Clazz implements I3, I2 {}

def method(I1 i1) { 'I1' }
def method(I3 i3) { 'I3' }

直接實作的介面將符合

assert method(new Clazz()) == 'I3'

Object 陣列優先於 Object。

def method(Object[] arg) { 'array' }
def method(Object arg) { 'object' }

assert method([] as Object[]) == 'array'

非可變參數變體優先於可變參數變體。

def method(String s, Object... vargs) { 'vararg' }
def method(String s) { 'non-vararg' }

assert method('foo') == 'non-vararg'

如果兩個可變參數變體都適用,則優先使用使用最少可變參數引數的那個。

def method(String s, Object... vargs) { 'two vargs' }
def method(String s, Integer i, Object... vargs) { 'one varg' }

assert method('foo', 35, new Date()) == 'one varg'

介面優先於超級類別。

interface I {}
class Base {}
class Child extends Base implements I {}

def method(Base b) { 'superclass' }
def method(I i) { 'interface' }

assert method(new Child()) == 'interface'

對於原始參數類型,優先使用相同或略大一點的宣告參數類型。

def method(Long l) { 'Long' }
def method(Short s) { 'Short' }
def method(BigInteger bi) { 'BigInteger' }

assert method(35) == 'Long'

在兩個變體具有完全相同距離的情況下,這被視為不明確,且會導致執行時期例外

def method(Date d, Object o) { 'd/o' }
def method(Object o, String s) { 'o/s' }

def ex = shouldFail {
    println method(new Date(), 'baz')
}
assert ex.message.contains('Ambiguous method overloading')

強制轉型可用於選取所需的 method

assert method(new Date(), (Object)'baz') == 'd/o'
assert method((Object)new Date(), 'baz') == 'o/s'

3.2.6. 例外宣告

Groovy 自動允許您將檢查例外視為未檢查例外。這表示您不需要宣告方法可能會擲回的任何檢查例外,如下列範例所示,如果找不到檔案,可能會擲回 FileNotFoundException

def badRead() {
    new File('doesNotExist.txt').text
}

shouldFail(FileNotFoundException) {
    badRead()
}

您也不需要在先前的範例中將對 badRead 方法的呼叫包圍在 try/catch 區塊中 - 儘管如果您願意,您可以這麼做。

如果您希望宣告您的程式碼可能會擲回的任何例外(無論是檢查例外或其他例外),您可以這麼做。加入例外並不會改變程式碼從任何其他 Groovy 程式碼中使用的任何方式,但可以視為對您程式碼的人類讀者來說的文件。這些例外將成為位元組碼中方法宣告的一部分,因此如果您的程式碼可能會從 Java 中呼叫,包含它們可能會很有用。下例說明了如何使用明確的檢查例外宣告

def badRead() throws FileNotFoundException {
    new File('doesNotExist.txt').text
}

shouldFail(FileNotFoundException) {
    badRead()
}

3.3. 欄位和屬性

3.3.1. 欄位

欄位是儲存資料的類別、介面或特質的成員。在 Groovy 原始檔中定義的欄位具有

  • 一個強制性的存取修飾詞publicprotectedprivate

  • 一個或多個選擇性的修飾詞staticfinalsynchronized

  • 一個選擇性的類型

  • 一個強制性的名稱

class Data {
    private int id                                  (1)
    protected String description                    (2)
    public static final boolean DEBUG = false       (3)
}
1 一個名為 idprivate 欄位,類型為 int
2 一個名為 descriptionprotected 欄位,類型為 String
3 一個名為 DEBUGpublic static final 欄位,類型為 boolean

一個欄位可以在宣告時直接初始化

class Data {
    private String id = IDGenerator.next() (1)
    // ...
}
1 私有欄位 id 使用 IDGenerator.next() 初始化

可以省略欄位的類型宣告。不過,這被視為不良習慣,一般而言,最好對欄位使用強類型

class BadPractice {
    private mapping                         (1)
}
class GoodPractice {
    private Map<String,String> mapping      (2)
}
1 欄位 mapping 沒有宣告類型
2 欄位 mapping 有強類型

如果您想要在稍後使用選擇性的類型檢查,這兩個之間的差異很重要。它也作為文件化類別設計的方式很重要。不過,在某些情況下,例如腳本編寫或如果您想要依賴鴨子類型,省略類型可能會很有用。

3.3.2. 屬性

屬性是類別的外部可見特徵。Java 中的典型做法是遵循 JavaBeans 規範 中概述的慣例,而不是僅使用公開欄位來表示此類特徵(這提供了更有限的抽象化,並會限制重構的可能性),也就是使用私有備份欄位和 getter/setter 組合來表示屬性。Groovy 遵循這些相同的慣例,但提供了一個更簡單的方式來定義屬性。您可以使用以下方式定義屬性

  • 一個沒有存取修飾詞(沒有 publicprotectedprivate

  • 一個或多個選擇性的修飾詞staticfinalsynchronized

  • 一個選擇性的類型

  • 一個強制性的名稱

然後 Groovy 會適當地產生 getter/setter。例如

class Person {
    String name                             (1)
    int age                                 (2)
}
1 建立一個備份的 private String name 欄位、一個 getName 和一個 setName 方法
2 建立一個備份的 private int age 欄位、一個 getAge 和一個 setAge 方法

如果一個屬性宣告為 final,則不會產生 setter

class Person {
    final String name                   (1)
    final int age                       (2)
    Person(String name, int age) {
        this.name = name                (3)
        this.age = age                  (4)
    }
}
1 定義一個 String 類型的唯讀屬性
2 定義一個 int 類型的唯讀屬性
3 name 參數指定給 name 欄位
4 age 參數指定給 age 欄位

透過名稱存取屬性,並會透明地呼叫 getter 或 setter,除非程式碼在定義屬性的類別中

class Person {
    String name
    void name(String name) {
        this.name = "Wonder $name"      (1)
    }
    String title() {
        this.name                       (2)
    }
}
def p = new Person()
p.name = 'Diana'                        (3)
assert p.name == 'Diana'                (4)
p.name('Woman')                         (5)
assert p.title() == 'Wonder Woman'      (6)
1 this.name 會直接存取欄位,因為屬性是由定義它的類別內部存取的
2 類似地,讀取存取會直接在 name 欄位上執行
3 寫入存取會在 Person 類別外部執行,因此會隱含地呼叫 setName
4 讀取存取會在 Person 類別外部執行,因此會隱含地呼叫 getName
5 這會在 Person 上呼叫 name 方法,而該方法會直接存取欄位
6 這會在 Person 上呼叫 title 方法,而該方法會直接讀取存取欄位

值得注意的是,直接存取後端欄位的行為,是為了防止在定義屬性的類別中使用屬性存取語法時發生堆疊溢位。

由於實例的 meta properties 欄位,因此可以列出類別的屬性

class Person {
    String name
    int age
}
def p = new Person()
assert p.properties.keySet().containsAll(['name','age'])

依慣例,即使沒有提供後端欄位,只要有遵循 Java Beans 規範的 getter 或 setter,Groovy 仍會辨識屬性。例如

class PseudoProperties {
    // a pseudo property "name"
    void setName(String name) {}
    String getName() {}

    // a pseudo read-only property "age"
    int getAge() { 42 }

    // a pseudo write-only property "groovy"
    void setGroovy(boolean groovy) {  }
}
def p = new PseudoProperties()
p.name = 'Foo'                      (1)
assert p.age == 42                  (2)
p.groovy = true                     (3)
1 寫入 p.name 是允許的,因為有一個偽屬性 name
2 讀取 p.age 是允許的,因為有一個偽唯讀屬性 age
3 寫入 p.groovy 是允許的,因為有一個偽唯寫屬性 groovy

這種語法糖是許多以 Groovy 編寫的 DSL 的核心。

屬性命名慣例

一般建議屬性名稱的前兩個字母使用小寫,而多字詞屬性則使用駝峰式大小寫。在這些情況下,產生的 getter 和 setter 會有一個名稱,其形成方式為將屬性名稱大寫,並加上 getset 前綴(或布林 getter 可選擇加上「is」)。因此,getLength 會是 length 屬性的 getter,而 setFirstName 會是 firstName 屬性的 setter。isEmpty 可能會是名為 empty 的屬性的 getter 方法名稱。

以大寫字母開頭的屬性名稱會產生僅加上前綴的 getter/setter。因此,屬性 Foo 是允許的,即使它不符合建議的命名慣例。對於這個屬性,存取器方法會是 setFoogetFoo。這會導致一個後果,就是您不能同時擁有 fooFoo 屬性,因為它們會有相同名稱的存取器方法。

JavaBeans 規範針對通常可能是縮寫的屬性制定了一個特例。如果屬性名稱的前兩個字母是大寫,則不執行大寫(或更重要的是,如果從存取器方法名稱產生屬性名稱,則不執行小寫)。因此,getURL 會是 URL 屬性的 getter。

由於 JavaBeans 規範中特殊的「縮寫處理」屬性命名邏輯,屬性名稱的轉換並非對稱的。這會導致一些奇怪的邊緣案例。Groovy 採用一種命名慣例,避免一種看似有點奇怪的歧義,但在 Groovy 設計時很流行,並一直保留下來(到目前為止)是因為歷史原因。Groovy 會查看屬性名稱的第二個字母。如果它是大寫,則屬性被視為縮寫樣式屬性之一,且不執行大寫,否則執行正常大寫。儘管我們絕不建議這樣做,但它確實允許您擁有看似「命名重複」的屬性,例如您可以擁有 aPropAProp,或 pNAMEPNAME。getter 會分別是 getaPropgetAProp,以及 getpNAMEgetPNAME

屬性上的修飾詞

我們已經看到屬性是透過省略可見性修飾詞來定義的。一般來說,任何其他修飾詞,例如 transient 會複製到欄位。值得注意的是兩個特例

  • final,我們之前看過,是針對唯讀屬性,它會複製到後援欄位,但也會導致不定義 setter

  • static 會複製到後援欄位,但也會導致存取器方法為靜態

如果您希望修飾詞(例如 final)也傳遞到存取器方法,您可以手寫您的屬性,或考慮使用分割屬性定義

屬性上的註解

註解,包括與 AST 轉換相關的註解,會複製到屬性的後備欄位。這允許適用於欄位的 AST 轉換套用到屬性,例如

class Animal {
    int lowerCount = 0
    @Lazy String name = { lower().toUpperCase() }()
    String lower() { lowerCount++; 'sloth' }
}

def a = new Animal()
assert a.lowerCount == 0  (1)
assert a.name == 'SLOTH'  (2)
assert a.lowerCount == 1  (3)
1 確認沒有急切初始化
2 一般屬性存取
3 確認在屬性存取時初始化
使用明確後備欄位分割屬性定義

當您的類別設計遵循與一般 JavaBean 實務相符的特定慣例時,Groovy 的屬性語法是一種方便的簡寫。如果您的類別不完全符合這些慣例,您當然可以像在 Java 中一樣,手動撰寫 getter、setter 和後備欄位。不過,Groovy 提供分割定義功能,仍然提供簡寫語法,同時允許對慣例進行輕微調整。對於分割定義,您撰寫欄位和屬性,名稱和類型相同。欄位或屬性中只能有一個具有初始值。

對於分割屬性,欄位上的註解會保留在屬性的後備欄位上。定義中屬性部分上的註解會複製到 getter 和 setter 方法上。

此機制允許多種一般變異,如果標準屬性定義不完全符合使用者需求,屬性使用者可能會希望使用這些變異。例如,如果後備欄位應為 protected 而不是 private

class HasPropertyWithProtectedField {
    protected String name  (1)
    String name            (2)
}
1 name 屬性的受保護後備欄位,而不是一般的私有後備欄位
2 宣告 name 屬性

或者,相同的範例,但使用封裝私有的後備欄位

class HasPropertyWithPackagePrivateField {
    String name                (1)
    @PackageScope String name  (2)
}
1 宣告 name 屬性
2 name 屬性的封裝私有後備欄位,而不是一般的私有後備欄位

最後一個範例,我們可能希望套用與方法相關的 AST 轉換,或一般來說,任何註解到 setter/getter,例如讓存取器同步

class HasPropertyWithSynchronizedAccessorMethods {
    private String name        (1)
    @Synchronized String name  (2)
}
1 name 屬性的後備欄位
2 宣告 name 屬性,並為 setter/getter 加上註解
明確存取器方法

如果類別中明確定義 getter 或 setter,則不會自動產生存取器方法。這允許您在需要時修改此類 getter 或 setter 的一般行為。通常不會考慮繼承的存取器方法,但如果繼承的存取器方法標記為 final,則也不會產生其他存取器方法,以符合此類方法不能有子類別的 final 需求。

4. 註解

4.1. 注解定義

註解是一種專門用於註解程式碼元素的特殊介面。註解是一種其超介面為 java.lang.annotation.Annotation 介面的類型。註解的宣告方式與介面非常類似,使用 @interface 關鍵字

@interface SomeAnnotation {}

註解可以定義成員,形式為沒有主體的方法和一個可選的預設值。可能的成員類型僅限於

例如

@interface SomeAnnotation {
    String value()                          (1)
}
@interface SomeAnnotation {
    String value() default 'something'      (2)
}
@interface SomeAnnotation {
    int step()                              (3)
}
@interface SomeAnnotation {
    Class appliesTo()                       (4)
}
@interface SomeAnnotation {}
@interface SomeAnnotations {
    SomeAnnotation[] value()                (5)
}
enum DayOfWeek { mon, tue, wed, thu, fri, sat, sun }
@interface Scheduled {
    DayOfWeek dayOfWeek()                   (6)
}
1 定義一個 value 成員類型為 String 的註解
2 定義一個 value 成員類型為 String 且預設值為 something 的註解
3 定義一個 step 成員類型為基本類型 int 的註解
4 定義一個 appliesTo 成員類型為 Class 的註解
5 定義一個 value 成員類型為另一個註解類型的陣列的註解
6 定義一個 dayOfWeek 成員類型為列舉類型 DayOfWeek 的註解

與 Java 語言不同的是,在 Groovy 中,註解可用於改變語言的語意。這在 AST 轉換中尤其如此,它會根據註解產生程式碼。

4.1.1. 註解放置

註解可以應用於程式碼的各種元素

@SomeAnnotation                 (1)
void someMethod() {
    // ...
}

@SomeAnnotation                 (2)
class SomeClass {}

@SomeAnnotation String var      (3)
1 @SomeAnnotation 應用於 someMethod 方法
2 @SomeAnnotation 應用於 SomeClass 類別
3 @SomeAnnotation 應用於 var 變數

為了限制註解可以應用的範圍,必須在註解定義中使用 java.lang.annotation.Target 註解宣告。例如,以下是宣告註解可以應用於類別或方法的方式

import java.lang.annotation.ElementType
import java.lang.annotation.Target

@Target([ElementType.METHOD, ElementType.TYPE])     (1)
@interface SomeAnnotation {}                        (2)
1 @Target 註解是用於為具有作用域的註解加上註解。
2 因此,@SomeAnnotation 將只允許在 TYPEMETHOD 上使用

可以在 java.lang.annotation.ElementType 中取得可能的目標清單。

Groovy 不支援在 Java 8 中引入的 java.lang.annotation.ElementType#TYPE_PARAMETERjava.lang.annotation.ElementType#TYPE_PARAMETER 元素類型。

4.1.2. 註解成員值

當使用註解時,必須至少設定所有沒有預設值的成員。例如

@interface Page {
    int statusCode()
}

@Page(statusCode=404)
void notFound() {
    // ...
}

然而,如果成員 value 是唯一一個被設定的成員,則可以在註解值的宣告中省略 value=

@interface Page {
    String value()
    int statusCode() default 200
}

@Page(value='/home')                    (1)
void home() {
    // ...
}

@Page('/users')                         (2)
void userList() {
    // ...
}

@Page(value='error',statusCode=404)     (3)
void notFound() {
    // ...
}
1 我們可以省略 statusCode,因為它有預設值,但 value 需要設定
2 由於 value 是唯一一個沒有預設值的強制成員,因此我們可以省略 value=
3 如果需要設定 valuestatusCode,則必須對預設 value 成員使用 value=

4.1.3. 保留政策

註解的可見性取決於其保留政策。註解的保留政策是使用 java.lang.annotation.Retention 註解設定的

import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy

@Retention(RetentionPolicy.SOURCE)                   (1)
@interface SomeAnnotation {}                         (2)
1 @Retention 註解為 @SomeAnnotation 註解加上註解
2 因此,@SomeAnnotation 將具有 SOURCE 保留

可以在 java.lang.annotation.RetentionPolicy 列舉中取得可能的保留目標和說明清單。選擇通常取決於您是否希望註解在編譯時或執行時可見。

4.1.4. 閉包註解參數

Groovy 中註解的一個有趣功能是,您可以使用閉包作為註解值。因此,註解可以用於各種表達式,並且仍然具有 IDE 支援。例如,想像一個框架,您希望根據環境限制(例如 JDK 版本或作業系統)執行一些方法。可以撰寫以下程式碼

class Tasks {
    Set result = []
    void alwaysExecuted() {
        result << 1
    }
    @OnlyIf({ jdk>=6 })
    void supportedOnlyInJDK6() {
        result << 'JDK 6'
    }
    @OnlyIf({ jdk>=7 && windows })
    void requiresJDK7AndWindows() {
        result << 'JDK 7 Windows'
    }
}

要讓 @OnlyIf 註解接受 Closure 作為引數,您只需要將 value 宣告為 Class

@Retention(RetentionPolicy.RUNTIME)
@interface OnlyIf {
    Class value()                    (1)
}

為了完成這個範例,讓我們撰寫一個會使用該資訊的範例執行器

class Runner {
    static <T> T run(Class<T> taskClass) {
        def tasks = taskClass.newInstance()                                         (1)
        def params = [jdk: 6, windows: false]                                       (2)
        tasks.class.declaredMethods.each { m ->                                     (3)
            if (Modifier.isPublic(m.modifiers) && m.parameterTypes.length == 0) {   (4)
                def onlyIf = m.getAnnotation(OnlyIf)                                (5)
                if (onlyIf) {
                    Closure cl = onlyIf.value().newInstance(tasks,tasks)            (6)
                    cl.delegate = params                                            (7)
                    if (cl()) {                                                     (8)
                        m.invoke(tasks)                                             (9)
                    }
                } else {
                    m.invoke(tasks)                                                 (10)
                }
            }
        }
        tasks                                                                       (11)
    }
}
1 建立傳入引數(工作類別)的類別新執行個體
2 模擬 JDK 6 且非 Windows 的環境
3 反覆執行工作類別中所有已宣告的方法
4 如果方法為公開且不帶引數
5 嘗試尋找 @OnlyIf 註解
6 如果找到,取得 value 並建立新的 Closure
7 將 closure 的 delegate 設定為我們的環境變數
8 呼叫 closure,也就是註解 closure。它會傳回 boolean
9 如果為 true,呼叫方法
10 如果方法未加上 @OnlyIf 註解,無論如何都執行方法
11 之後,傳回工作物件

然後,執行器可以使用這種方式

def tasks = Runner.run(Tasks)
assert tasks.result == [1, 'JDK 6'] as Set

4.2. 元註解

4.2.1. 宣告元註解

元註解,也稱為註解別名,是在編譯時由其他註解取代的註解(一個元註解是多個註解的別名)。元註解可用於縮減包含多個註解的程式碼大小。

我們從一個簡單的範例開始。想像您有 @Service@Transactional 註解,而且您想同時為一個類別加上這兩個註解

@Service
@Transactional
class MyTransactionalService {}

考量到您可以新增到同一個類別的註解數量,元註解可以透過將兩個註解縮減為一個具有完全相同語意的註解來提供協助。例如,我們可能想要改寫成這樣

@TransactionalService                           (1)
class MyTransactionalService {}
1 @TransactionalService 是元註解

元註解會宣告為一般註解,但加上 @AnnotationCollector 註解和它所收集的註解清單。在我們的案例中,@TransactionalService 註解可以寫成

import groovy.transform.AnnotationCollector

@Service                                        (1)
@Transactional                                  (2)
@AnnotationCollector                            (3)
@interface TransactionalService {
}
1 使用 @Service 註解元註解
2 使用 @Transactional 註解元註解
3 使用 @AnnotationCollector 註解元註解

4.2.2. 元註解的行為

Groovy 支援預編譯原始程式碼形式的元註解。這表示您的元註解可能會預編譯,或者您可以將它放在與您目前編譯的程式碼相同的原始程式碼樹中。

資訊:元註解是 Groovy 獨有的功能。您無法使用元註解註解 Java 類別,並希望它會執行與 Groovy 中相同的功能。同樣地,您無法在 Java 中撰寫元註解:元註解定義使用都必須是 Groovy 程式碼。但是,您可以在元註解中快樂地收集 Java 註解和 Groovy 註解。

當 Groovy 編譯器遇到使用元註解註解的類別時,它會替換為收集的註解。因此,在我們之前的範例中,它會將 @TransactionalService 替換為 @Transactional@Service

def annotations = MyTransactionalService.annotations*.annotationType()
assert (Service in annotations)
assert (Transactional in annotations)

從元註解轉換為收集的註解是在語意分析編譯階段執行的。

除了將別名替換為收集的註解之外,元註解還能處理它們,包括參數。

4.2.3. 元註解參數

元註解可以收集具有參數的註解。為了說明這一點,我們將想像兩個註解,每個註解都接受一個參數

@Timeout(after=3600)
@Dangerous(type='explosive')

並假設您想要建立一個名為 @Explosive 的元註解

@Timeout(after=3600)
@Dangerous(type='explosive')
@AnnotationCollector
public @interface Explosive {}

預設情況下,當註解被替換時,它們會取得註解參數值,就像它們在別名中定義的那樣。更有趣的是,元註解支援覆寫特定值

@Explosive(after=0)                 (1)
class Bomb {}
1 提供給 @Explosive 作為參數的 after 值會覆寫在 @Timeout 註解中定義的值

如果兩個註解定義相同的參數名稱,預設處理器會將註解值複製到所有接受此參數的註解

@Retention(RetentionPolicy.RUNTIME)
public @interface Foo {
   String value()                                   (1)
}
@Retention(RetentionPolicy.RUNTIME)
public @interface Bar {
    String value()                                  (2)
}

@Foo
@Bar
@AnnotationCollector
public @interface FooBar {}                         (3)

@Foo('a')
@Bar('b')
class Bob {}                                        (4)

assert Bob.getAnnotation(Foo).value() == 'a'        (5)
println Bob.getAnnotation(Bar).value() == 'b'       (6)

@FooBar('a')
class Joe {}                                        (7)
assert Joe.getAnnotation(Foo).value() == 'a'        (8)
println Joe.getAnnotation(Bar).value() == 'a'       (9)
1 @Foo 註解定義類型為 Stringvalue 成員
2 @Bar 註解也定義類型為 Stringvalue 成員
3 @FooBar 元註解彙總 @Foo@Bar
4 類別 Bob 使用 @Foo@Bar 註解
5 Bob@Foo 註解的值是 a
6 Bob@Bar 註解的值是 b
7 類別 Joe 使用 @FooBar 註解
8 然後 Joe@Foo 註解的值是 a
9 Joe@Bar 註解的值也是 a

在第二個案例中,元註解值被複製到 @Foo@Bar 註解中。

如果收集的註解定義具有不相容類型的相同成員,則會產生編譯時間錯誤。例如,如果在先前的範例中 @Foo 定義類型為 String 的值,但 @Bar 定義類型為 int 的值。

然而,自訂元註解的行為並描述收集的註解如何擴充是可行的。我們將在稍後探討如何執行,但首先有一個進階處理選項需要涵蓋。

4.2.4. 處理元註解中的重複註解

@AnnotationCollector 註解支援 mode 參數,可用於變更預設處理器在存在重複註解時處理註解替換的方式。

資訊:自訂處理器(稍後討論)可能支援或不支援此參數。

舉例來說,假設您建立包含 @ToString 註解的元註解,然後將元註解置於已具有明確 @ToString 註解的類別上。這會是錯誤嗎?兩個註解都應該套用嗎?一個會優先於另一個嗎?沒有正確答案。在某些情況下,這些答案中的任何一個都可能是正確的。因此,Groovy 讓您撰寫自己的自訂元註解處理器(稍後涵蓋)並讓您撰寫任何您喜歡的檢查邏輯,而不是試圖搶先一步處理重複註解問題,而 AST 轉換是經常彙總的目標。話雖如此,只需設定 mode,即可在任何額外編碼中自動處理許多常見預期情況。mode 參數的行為由所選的 AnnotationCollectorMode 列舉值決定,並在以下表格中總結。

模式

說明

DUPLICATE

註解收集中的註解將永遠插入。在執行所有轉換後,如果存在多個註解(排除那些具有 SOURCE 保留的註解),則會發生錯誤。

PREFER_COLLECTOR

將新增收集器中的註解,並移除任何具有相同名稱的現有註解。

PREFER_COLLECTOR_MERGED

將新增收集器中的註解,並移除任何具有相同名稱的現有註解,但現有註解中找到的任何新參數將合併到新增的註解中。

PREFER_EXPLICIT

如果找到任何具有相同名稱的現有註解,則將忽略收集器中的註解。

PREFER_EXPLICIT_MERGED

如果找到任何具有相同名稱的現有註解,則將忽略收集器中的註解,但收集器註解上的任何新參數將新增到現有註解中。

4.2.5. 自訂元註解處理器

自訂註解處理器可讓您選擇如何將元註解擴充為收集的註解。在此情況下,元註解的行為完全取決於您。為執行此操作,您必須

為說明此點,我們將探討如何實作元註解 @CompileDynamic

@CompileDynamic 是一個元註解,可將其自身擴充為 @CompileStatic(TypeCheckingMode.SKIP)。問題在於,預設的元註解處理器不支援列舉,而註解值 TypeCheckingMode.SKIP 就是其中之一。

以下天真的實作將無法運作

@CompileStatic(TypeCheckingMode.SKIP)
@AnnotationCollector
public @interface CompileDynamic {}

相反地,我們將定義如下

@AnnotationCollector(processor = "org.codehaus.groovy.transform.CompileDynamicProcessor")
public @interface CompileDynamic {
}

您可能首先會注意到,我們的介面不再註解為 @CompileStatic。原因在於,我們改為依賴 processor 參數,它參照一個將 產生 註解的類別。

以下是自訂處理器的實作方式

CompileDynamicProcessor.groovy
@CompileStatic                                                                  (1)
class CompileDynamicProcessor extends AnnotationCollectorTransform {            (2)
    private static final ClassNode CS_NODE = ClassHelper.make(CompileStatic)    (3)
    private static final ClassNode TC_NODE = ClassHelper.make(TypeCheckingMode) (4)

    List<AnnotationNode> visit(AnnotationNode collector,                        (5)
                               AnnotationNode aliasAnnotationUsage,             (6)
                               AnnotatedNode aliasAnnotated,                    (7)
                               SourceUnit source) {                             (8)
        def node = new AnnotationNode(CS_NODE)                                  (9)
        def enumRef = new PropertyExpression(
            new ClassExpression(TC_NODE), "SKIP")                               (10)
        node.addMember("value", enumRef)                                        (11)
        Collections.singletonList(node)                                         (12)
    }
}
1 我們的自訂處理器是用 Groovy 編寫,且為了獲得更好的編譯效能,我們使用靜態編譯
2 自訂處理器必須延伸 org.codehaus.groovy.transform.AnnotationCollectorTransform
3 建立代表 @CompileStatic 註解類型的類別節點
4 建立代表 TypeCheckingMode 列舉類型的類別節點
5 collector 是在元註解中找到的 @AnnotationCollector 節點。通常不會使用。
6 aliasAnnotationUsage 是要擴充的元註解,在此為 @CompileDynamic
7 aliasAnnotated 是使用元註解註解的節點
8 sourceUnit 是正在編譯的 SourceUnit
9 我們為 @CompileStatic 建立新的註解節點
10 我們建立等同於 TypeCheckingMode.SKIP 的表達式
11 我們將該表達式新增至註解節點,它現在為 @CompileStatic(TypeCheckingMode.SKIP)
12 傳回已產生的註解

在範例中,visit 方法是唯一必須覆寫的方法。它的用意是傳回將新增至使用元註解註解的節點的註解節點清單。在此範例中,我們傳回一個對應於 @CompileStatic(TypeCheckingMode.SKIP) 的單一節點。

5. 特質

特質是語言的結構建構,允許

  • 行為組合

  • 介面的執行時期實作

  • 行為覆寫

  • 與靜態類型檢查/編譯相容

它們可以視為介面,同時具有預設實作狀態。使用 trait 關鍵字定義特質

trait FlyingAbility {                           (1)
        String fly() { "I'm flying!" }          (2)
}
1 特質宣告
2 特質內方法宣告

然後可以使用 implements 關鍵字,像一般介面一樣使用

class Bird implements FlyingAbility {}          (1)
def b = new Bird()                              (2)
assert b.fly() == "I'm flying!"                 (3)
1 FlyingAbility 特質新增到 Bird 類別功能
2 實例化新的 Bird
3 Bird 類別會自動取得 FlyingAbility 特質的行為

特質允許廣泛的功能,從簡單的組合到測試,本節將詳細說明。

5.1. 方法

5.1.1. 公開方法

在特質中宣告方法,可以像在類別中宣告一般方法一樣

trait FlyingAbility {                           (1)
        String fly() { "I'm flying!" }          (2)
}
1 特質宣告
2 特質內方法宣告

5.1.2. 抽象方法

此外,特質也可以宣告抽象方法,因此需要在實作特質的類別中實作

trait Greetable {
    abstract String name()                              (1)
    String greeting() { "Hello, ${name()}!" }           (2)
}
1 實作類別必須宣告 name 方法
2 可以與具體方法混合

然後可以像這樣使用特質

class Person implements Greetable {                     (1)
    String name() { 'Bob' }                             (2)
}

def p = new Person()
assert p.greeting() == 'Hello, Bob!'                    (3)
1 實作 Greetable 特質
2 由於 name 是抽象的,因此必須實作
3 然後可以呼叫 greeting

5.1.3. 私有方法

特質也可以定義私有方法。這些方法不會出現在特質合約介面中

trait Greeter {
    private String greetingMessage() {                      (1)
        'Hello from a private method!'
    }
    String greet() {
        def m = greetingMessage()                           (2)
        println m
        m
    }
}
class GreetingMachine implements Greeter {}                 (3)
def g = new GreetingMachine()
assert g.greet() == "Hello from a private method!"          (4)
try {
    assert g.greetingMessage()                              (5)
} catch (MissingMethodException e) {
    println "greetingMessage is private in trait"
}
1 在特質中定義私有方法 greetingMessage
2 公開的 greet 訊息預設呼叫 greetingMessage
3 建立實作特質的類別
4 可以呼叫 greet
5 但不能呼叫 greetingMessage
特質僅支援 publicprivate 方法。不支援 protectedpackage private 範圍。

5.1.4. 最終方法

如果我們有一個實作特質的類別,從概念上來說,特質方法的實作會「繼承」到類別中。但實際上,沒有包含這些實作的基底類別。相反地,它們直接編織到類別中。方法上的 final 修飾詞僅表示編織方法的修飾詞是什麼。雖然繼承和覆寫或多重繼承具有相同簽章但具有 final 和非 final 變體的方法可能會被認為是不好的風格,但 Groovy 並不禁止這種情況。套用一般方法選擇,並且將根據結果方法確定所使用的修飾詞。如果您想要無法覆寫的特質實作方法,可以考慮建立實作所需特質的基底類別。

5.2. 此的意義

this 代表實作實例。將特質視為超類別。這表示當您撰寫

trait Introspector {
    def whoAmI() { this }
}
class Foo implements Introspector {}
def foo = new Foo()

然後呼叫

foo.whoAmI()

會傳回相同的實例

assert foo.whoAmI().is(foo)

5.3. 介面

特質可以實作介面,在這種情況下,介面會使用 implements 關鍵字宣告

interface Named {                                       (1)
    String name()
}
trait Greetable implements Named {                      (2)
    String greeting() { "Hello, ${name()}!" }
}
class Person implements Greetable {                     (3)
    String name() { 'Bob' }                             (4)
}

def p = new Person()
assert p.greeting() == 'Hello, Bob!'                    (5)
assert p instanceof Named                               (6)
assert p instanceof Greetable                           (7)
1 一般介面的宣告
2 Named 新增至已實作介面的清單
3 宣告一個實作 Greetable 特質的類別
4 實作遺失的 name 方法
5 greeting 實作來自特質
6 確定 Person 實作 Named 介面
7 確定 Person 實作 Greetable 特質

5.4. 屬性

特質可以定義屬性,如下列範例所示

trait Named {
    String name                             (1)
}
class Person implements Named {}            (2)
def p = new Person(name: 'Bob')             (3)
assert p.name == 'Bob'                      (4)
assert p.getName() == 'Bob'                 (5)
1 在特質內宣告屬性 name
2 宣告一個實作特質的類別
3 屬性會自動顯示
4 可以使用一般屬性存取器存取
5 或使用一般 getter 語法存取

5.5. 欄位

5.5.1. 私有欄位

由於特質允許使用私有方法,因此使用私有欄位來儲存狀態也會很有趣。特質會讓您這麼做

trait Counter {
    private int count = 0                   (1)
    int count() { count += 1; count }       (2)
}
class Foo implements Counter {}             (3)
def f = new Foo()
assert f.count() == 1                       (4)
assert f.count() == 2
1 在特質內宣告私有欄位 count
2 宣告一個公用方法 count,用來遞增計數器並傳回
3 宣告一個實作 Counter 特質的類別
4 count 方法可以使用私有欄位來保留狀態
這是與 Java 8 虛擬延伸方法 的主要差異。虛擬延伸方法不會傳遞狀態,但特質可以。此外,Groovy 中的特質從 Java 6 開始支援,因為其實作不依賴於虛擬延伸方法。這表示即使特質可以從 Java 類別視為一般介面,該介面不會有預設方法,只有抽象方法。

5.5.2. 公用欄位

公用欄位的工作方式與私有欄位相同,但為了避免 菱形問題,欄位名稱會在實作類別中重新對應

trait Named {
    public String name                      (1)
}
class Person implements Named {}            (2)
def p = new Person()                        (3)
p.Named__name = 'Bob'                       (4)
1 在特質內宣告一個公用欄位
2 宣告一個實作特質的類別
3 建立該類別的實例
4 公用欄位可用,但已重新命名

欄位的名稱取決於特徵的完整限定名稱。套件中的所有點 (.) 都會替換為底線 (_),而最後的名稱會包含兩個底線。因此,如果欄位的類型是 String,套件的名稱是 my.package,特徵的名稱是 Foo,而欄位的名稱是 bar,在實作類別中,公開欄位會顯示為

String my_package_Foo__bar
雖然特徵支援公開欄位,但建議不要使用,且視為不良習慣。

5.6. 行為組合

特徵可以用來以受控的方式實作多重繼承。例如,我們可以有下列特徵

trait FlyingAbility {                           (1)
        String fly() { "I'm flying!" }          (2)
}
trait SpeakingAbility {
    String speak() { "I'm speaking!" }
}

和實作兩個特徵的類別

class Duck implements FlyingAbility, SpeakingAbility {} (1)

def d = new Duck()                                      (2)
assert d.fly() == "I'm flying!"                         (3)
assert d.speak() == "I'm speaking!"                     (4)
1 Duck 類別實作 FlyingAbilitySpeakingAbility
2 建立 Duck 的新執行個體
3 我們可以從 FlyingAbility 呼叫方法 fly
4 但也可以從 SpeakingAbility 呼叫方法 speak

特徵鼓勵物件之間功能的重複使用,以及透過組合既有行為來建立新類別。

5.7. 覆寫預設方法

特徵提供方法的預設實作,但可以在實作類別中覆寫它們。例如,我們可以稍微變更上述範例,讓鴨子會呱呱叫

class Duck implements FlyingAbility, SpeakingAbility {
    String quack() { "Quack!" }                         (1)
    String speak() { quack() }                          (2)
}

def d = new Duck()
assert d.fly() == "I'm flying!"                         (3)
assert d.quack() == "Quack!"                            (4)
assert d.speak() == "Quack!"                            (5)
1 定義一個特定於 Duck 的方法,稱為 quack
2 覆寫 speak 的預設實作,以便改用 quack
3 鴨子仍然會飛,這是來自預設實作
4 quack 來自 Duck 類別
5 speak 不再使用 SpeakingAbility 的預設實作

5.8. 延伸特徵

5.8.1. 簡單繼承

特徵可以延伸另一個特徵,這種情況下你必須使用 extends 關鍵字

trait Named {
    String name                                     (1)
}
trait Polite extends Named {                        (2)
    String introduce() { "Hello, I am $name" }      (3)
}
class Person implements Polite {}
def p = new Person(name: 'Alice')                   (4)
assert p.introduce() == 'Hello, I am Alice'         (5)
1 Named 特徵定義一個單一的 name 屬性
2 Polite 特徵延伸Named 特徵
3 Polite 新增一個新的方法,可以存取超特徵的 name 屬性
4 name 屬性可從實作 PolitePerson 類別中看見
5 introduce 方法也是

5.8.2. 多重繼承

或者,一個特質可以延伸多個特質。在這種情況下,所有父特質都必須在 implements 子句中宣告

trait WithId {                                      (1)
    Long id
}
trait WithName {                                    (2)
    String name
}
trait Identified implements WithId, WithName {}     (3)
1 WithId 特質定義 id 屬性
2 WithName 特質定義 name 屬性
3 Identified 是一個特質,繼承 WithIdWithName

5.9. 鴨子型別和特質

5.9.1. 動態程式碼

特質可以呼叫任何動態程式碼,就像一般的 Groovy 類別。這表示你可以在方法主體中呼叫實作類別中假設存在的函式,而不需要在介面中明確宣告它們。這表示特質與鴨子型別完全相容

trait SpeakingDuck {
    String speak() { quack() }                      (1)
}
class Duck implements SpeakingDuck {
    String methodMissing(String name, args) {
        "${name.capitalize()}!"                     (2)
    }
}
def d = new Duck()
assert d.speak() == 'Quack!'                        (3)
1 SpeakingDuck 預期 quack 方法已定義
2 Duck 類別使用 methodMissing 實作該方法
3 呼叫 speak 方法會觸發對 quack 的呼叫,而 methodMissing 會處理此呼叫

5.9.2. 特質中的動態方法

特質也可以實作 MOP 方法,例如 methodMissingpropertyMissing,在這種情況下,實作類別會繼承特質中的行為,就像這個範例

trait DynamicObject {                               (1)
    private Map props = [:]
    def methodMissing(String name, args) {
        name.toUpperCase()
    }
    def propertyMissing(String name) {
        props.get(name)
    }
    void setProperty(String name, Object value) {
        props.put(name, value)
    }
}

class Dynamic implements DynamicObject {
    String existingProperty = 'ok'                  (2)
    String existingMethod() { 'ok' }                (3)
}
def d = new Dynamic()
assert d.existingProperty == 'ok'                   (4)
assert d.foo == null                                (5)
d.foo = 'bar'                                       (6)
assert d.foo == 'bar'                               (7)
assert d.existingMethod() == 'ok'                   (8)
assert d.someMethod() == 'SOMEMETHOD'               (9)
1 建立一個實作多個 MOP 方法的特質
2 Dynamic 類別定義一個屬性
3 Dynamic 類別定義一個方法
4 呼叫現有屬性會呼叫 Dynamic 中的方法
5 呼叫不存在的屬性會呼叫特質中的方法
6 會呼叫特質中定義的 setProperty
7 會呼叫特質中定義的 getProperty
8 呼叫 Dynamic 上現有的方法
9 但呼叫不存在的方法,感謝 methodMissing 特質

5.10. 多重繼承衝突

5.10.1. 預設衝突解決

一個類別有可能實作多個特質。如果某個特質定義一個方法,其簽章與另一個特質中的方法相同,我們就會遇到衝突

trait A {
    String exec() { 'A' }               (1)
}
trait B {
    String exec() { 'B' }               (2)
}
class C implements A,B {}               (3)
1 特質 A 定義一個名為 exec 的方法,傳回一個 String
2 特質 B 定義完全相同的方法
3 類別 C 實作這兩個特質

在這種情況下,預設行為是 implements 子句中最後宣告的特質中的方法會獲勝。這裡,BA 之後宣告,所以會選取 B 中的方法

def c = new C()
assert c.exec() == 'B'

5.10.2. 使用者衝突解決

如果這種行為不是您想要的,您可以使用 Trait.super.foo 語法明確選擇要呼叫哪個方法。在上面的範例中,我們可以透過撰寫以下內容來確保呼叫特質 A 中的方法

class C implements A,B {
    String exec() { A.super.exec() }    (1)
}
def c = new C()
assert c.exec() == 'A'                  (2)
1 從特質 A 中明確呼叫 exec
2 呼叫 A 中的版本,而不是使用預設解決方案,預設解決方案會呼叫 B 中的版本

5.11. 特質的執行時期實作

5.11.1. 在執行時期實作特質

Groovy 也支援在執行時期動態實作特質。它允許您使用特質「裝飾」現有的物件。舉例來說,我們從這個特質和以下類別開始

trait Extra {
    String extra() { "I'm an extra method" }            (1)
}
class Something {                                       (2)
    String doSomething() { 'Something' }                (3)
}
1 Extra 特質定義一個 extra 方法
2 Something 類別沒有實作 Extra 特質
3 Something 只定義一個方法 doSomething

然後如果我們執行

def s = new Something()
s.extra()

呼叫 extra 會失敗,因為 Something 沒有實作 Extra。可以在執行時期使用以下語法來執行此動作

def s = new Something() as Extra                        (1)
s.extra()                                               (2)
s.doSomething()                                         (3)
1 使用 as 關鍵字在執行時期將物件強制轉換為特質
2 然後可以在物件上呼叫 extra
3 而且仍然可以呼叫 doSomething
在將物件強制轉換為特質時,操作的結果與原來的執行個體不同。可以保證強制轉換的物件會同時實作特質原物件實作的介面,但結果不會是原類別的執行個體。

5.11.2. 一次實作多個特質

如果您需要一次實作多個特質,您可以使用 withTraits 方法,而不是 as 關鍵字

trait A { void methodFromA() {} }
trait B { void methodFromB() {} }

class C {}

def c = new C()
c.methodFromA()                     (1)
c.methodFromB()                     (2)
def d = c.withTraits A, B           (3)
d.methodFromA()                     (4)
d.methodFromB()                     (5)
1 呼叫 methodFromA 會失敗,因為 C 沒有實作 A
2 呼叫 methodFromB 會失敗,因為 C 沒有實作 B
3 withTrait 會將 c 包裝成實作 AB 的物件
4 methodFromA 現在會通過,因為 d 實作 A
5 methodFromB 現在會通過,因為 d 也實作 B
當將物件轉換為多個特質時,操作的結果並非同一個實例。保證轉換後的物件會同時實作特質 原始物件實作的介面,但結果 不會 是原始類別的實例。

5.12. 串連行為

Groovy 支援 可堆疊特質 的概念。這個概念是如果目前的特質無法處理訊息,就將訊息委派給另一個特質。為了說明這一點,我們想像一個訊息處理器介面像這樣

interface MessageHandler {
    void on(String message, Map payload)
}

然後你可以透過套用小型行為來組合訊息處理器。例如,我們以特質的形式定義一個預設處理器

trait DefaultHandler implements MessageHandler {
    void on(String message, Map payload) {
        println "Received $message with payload $payload"
    }
}

然後任何類別都可以透過實作特質來繼承預設處理器的行為

class SimpleHandler implements DefaultHandler {}

現在,如果你想要在預設處理器之外記錄所有訊息呢?一個選項是寫下這個

class SimpleHandlerWithLogging implements DefaultHandler {
    void on(String message, Map payload) {                                  (1)
        println "Seeing $message with payload $payload"                     (2)
        DefaultHandler.super.on(message, payload)                           (3)
    }
}
1 明確實作 on 方法
2 執行記錄
3 繼續委派給 DefaultHandler 特質

這會奏效,但這種方法有缺點

  1. 記錄邏輯與「具體」處理器綁定

  2. 我們在 on 方法中明確參照 DefaultHandler,這表示如果我們碰巧變更我們的類別實作的特質,程式碼就會中斷

作為替代方案,我們可以寫另一個特質,其責任僅限於記錄

trait LoggingHandler implements MessageHandler {                            (1)
    void on(String message, Map payload) {
        println "Seeing $message with payload $payload"                     (2)
        super.on(message, payload)                                          (3)
    }
}
1 記錄處理器本身是一個處理器
2 列印它接收到的訊息
3 然後 super 讓它將呼叫委派給串連中的下一個特質

然後我們的類別可以改寫成這樣

class HandlerWithLogger implements DefaultHandler, LoggingHandler {}
def loggingHandler = new HandlerWithLogger()
loggingHandler.on('test logging', [:])

它會列印

Seeing test logging with payload [:]
Received test logging with payload [:]

優先順序規則暗示,由於 LoggerHandler 最後宣告,因此會獲勝,然後呼叫 on 會使用 LoggingHandler 的實作。但後者有呼叫 super,表示鏈中的下一個特質。在此,下一個特質是 DefaultHandler,因此兩個都會被呼叫

如果我們新增第三個處理常式,負責處理以 say 開頭的訊息,這個方法的優點會更明顯

trait SayHandler implements MessageHandler {
    void on(String message, Map payload) {
        if (message.startsWith("say")) {                                    (1)
            println "I say ${message - 'say'}!"
        } else {
            super.on(message, payload)                                      (2)
        }
    }
}
1 處理常式特定前提條件
2 如果未符合前提條件,將訊息傳遞給鏈中的下一個處理常式

然後我們的最終處理常式如下所示

class Handler implements DefaultHandler, SayHandler, LoggingHandler {}
def h = new Handler()
h.on('foo', [:])
h.on('sayHello', [:])

表示

  • 訊息會先透過記錄處理常式

  • 記錄處理常式呼叫 super,它會委派給下一個處理常式,也就是 SayHandler

  • 如果訊息以 say 開頭,則處理常式會使用訊息

  • 如果沒有,則 say 處理常式會委派給鏈中的下一個處理常式

這個方法非常強大,因為它允許您撰寫彼此不認識的處理常式,但仍能讓您按照想要的順序將它們組合在一起。例如,如果我們執行程式碼,它會列印

Seeing foo with payload [:]
Received foo with payload [:]
Seeing sayHello with payload [:]
I say Hello!

但如果我們將記錄處理常式移到鏈中的第二個,輸出就會不同

class AlternateHandler implements DefaultHandler, LoggingHandler, SayHandler {}
h = new AlternateHandler()
h.on('foo', [:])
h.on('sayHello', [:])

列印

Seeing foo with payload [:]
Received foo with payload [:]
I say Hello!

原因是現在,由於 SayHandler 使用訊息而不呼叫 super,因此不再呼叫記錄處理常式。

5.12.1. 特質內部 super 的語意

如果類別實作多個特質,且找到呼叫非限定的 super,則

  1. 如果類別實作另一個特質,呼叫會委派給鏈中的下一個特質

  2. 如果鏈中沒有任何特質,super 會參照實作類別的超類別 (this)

例如,由於這個行為,可以裝飾 final 類別

trait Filtering {                                       (1)
    StringBuilder append(String str) {                  (2)
        def subst = str.replace('o','')                 (3)
        super.append(subst)                             (4)
    }
    String toString() { super.toString() }              (5)
}
def sb = new StringBuilder().withTraits Filtering       (6)
sb.append('Groovy')
assert sb.toString() == 'Grvy'                          (7)
1 定義名為 Filtering 的特質,假設在執行階段套用在 StringBuilder
2 重新定義 append 方法
3 移除字串中所有「o」
4 然後委派給super
5 如果呼叫toString,委派給super.toString
6 StringBuilder實例上執行Filtering特徵的執行時期實作
7 附加的字串不再包含字母o

在此範例中,當遇到super.append時,目標物件沒有實作其他特徵,因此呼叫的方法是原始的append方法,也就是來自StringBuilder的方法。toString也使用相同的技巧,因此產生代理物件的字串表示委派給StringBuilder實例的toString

5.13. 進階功能

5.13.1. SAM 型別強制轉換

如果特徵定義單一抽象方法,它就是 SAM (單一抽象方法) 型別強制轉換的候選者。例如,想像下列特徵

trait Greeter {
    String greet() { "Hello $name" }        (1)
    abstract String getName()               (2)
}
1 greet方法不是抽象的,且呼叫抽象方法getName
2 getName是抽象方法

由於getNameGreeter特徵中的單一抽象方法,因此您可以撰寫

Greeter greeter = { 'Alice' }               (1)
1 封閉式「成為」getName單一抽象方法的實作

甚至

void greet(Greeter g) { println g.greet() } (1)
greet { 'Alice' }                           (2)
1 greet 方法接受 SAM 型別 Greeter 作為參數
2 我們可以使用封閉式直接呼叫它

5.13.2. 與 Java 8 預設方法的差異

在 Java 8 中,介面可以有方法的預設實作。如果類別實作介面且未提供預設方法的實作,則會選取介面中的實作。特徵的行為相同,但有一個主要差異:如果類別在介面清單中宣告特徵未提供實作,則總是使用特徵中的實作,即使超類別有提供實作。

此功能可讓您以非常精確的方式編寫行為,以防您想要覆寫已實作方法的行為。

為了說明這個概念,讓我們從這個簡單的範例開始

import groovy.test.GroovyTestCase
import groovy.transform.CompileStatic
import org.codehaus.groovy.control.CompilerConfiguration
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import org.codehaus.groovy.control.customizers.ImportCustomizer

class SomeTest extends GroovyTestCase {
    def config
    def shell

    void setup() {
        config = new CompilerConfiguration()
        shell = new GroovyShell(config)
    }
    void testSomething() {
        assert shell.evaluate('1+1') == 2
    }
    void otherTest() { /* ... */ }
}

在此範例中,我們建立一個簡單的測試案例,它使用兩個屬性 (configshell),並在多個測試方法中使用它們。現在想像一下,您想要測試相同的內容,但使用另一個不同的編譯器組態。其中一個選項是建立 SomeTest 的子類別

class AnotherTest extends SomeTest {
    void setup() {
        config = new CompilerConfiguration()
        config.addCompilationCustomizers( ... )
        shell = new GroovyShell(config)
    }
}

它有效,但如果您實際上有多個測試類別,且您想要為所有這些測試類別測試新的組態,那該怎麼辦?那麼您必須為每個測試類別建立一個不同的子類別

class YetAnotherTest extends SomeTest {
    void setup() {
        config = new CompilerConfiguration()
        config.addCompilationCustomizers( ... )
        shell = new GroovyShell(config)
    }
}

然後您會看到兩個測試的 setup 方法相同。因此,這個概念是建立一個特質

trait MyTestSupport {
    void setup() {
        config = new CompilerConfiguration()
        config.addCompilationCustomizers( new ASTTransformationCustomizer(CompileStatic) )
        shell = new GroovyShell(config)
    }
}

然後在子類別中使用它

class AnotherTest extends SomeTest implements MyTestSupport {}
class YetAnotherTest extends SomeTest2 implements MyTestSupport {}
...

它將允許我們大幅減少樣板程式碼,並降低忘記變更設定程式碼的風險,以防我們決定變更它。即使 setup 已在超級類別中實作,由於測試類別在其介面清單中宣告特質,因此行為將從特質實作中借用!

當您無法存取超級類別原始程式碼時,此功能特別有用。它可用於模擬方法或強制在子類別中實作某個特定方法。它讓您可以重構程式碼,將覆寫的邏輯保留在單一特質中,並僅透過實作它來繼承新行為。當然,另一種替代方法是在您會使用新程式碼的每個地方覆寫方法。

值得注意的是,如果您使用執行時期特質,則特質中的方法總是優先於代理物件的方法
class Person {
    String name                                         (1)
}
trait Bob {
    String getName() { 'Bob' }                          (2)
}

def p = new Person(name: 'Alice')
assert p.name == 'Alice'                                (3)
def p2 = p as Bob                                       (4)
assert p2.name == 'Bob'                                 (5)
1 Person 類別定義一個 name 屬性,其結果為 getName 方法
2 Bob 是定義 getName 為傳回 Bob 的特質
3 預設物件將傳回 Alice
4 p2 在執行時期將 p 轉換為 Bob
5 getName 傳回 Bob,因為 getName 取自特質
再次提醒您,動態特質轉換傳回一個不同的物件,它只實作原始介面以及特質。

5.14. 與混入的差異

與 Groovy 中提供的 mixin 有幾個概念上的差異。請注意,我們討論的是執行時期 mixin,而不是已棄用且建議改用特徵的 @Mixin 註解。

首先,在特徵中定義的方法在位元組碼中可見

  • 在內部,特徵表示為介面(沒有預設或靜態方法)和幾個輔助類別

  • 這表示實作特徵的物件實際上實作了介面

  • 這些方法從 Java 可見

  • 它們與類型檢查和靜態編譯相容

反之,透過 mixin 新增的方法僅在執行時期可見

class A { String methodFromA() { 'A' } }        (1)
class B { String methodFromB() { 'B' } }        (2)
A.metaClass.mixin B                             (3)
def o = new A()
assert o.methodFromA() == 'A'                   (4)
assert o.methodFromB() == 'B'                   (5)
assert o instanceof A                           (6)
assert !(o instanceof B)                        (7)
1 類別 A 定義 methodFromA
2 類別 B 定義 methodFromB
3 將 mixin B 混入 A
4 我們可以呼叫 methodFromA
5 我們也可以呼叫 methodFromB
6 物件是 A 的執行個體
7 但它不是 B 的執行個體

最後一點實際上非常重要,並說明了 mixin 優於特徵的地方:執行個體不會被修改,因此如果您將某個類別混入另一個類別,則不會產生第三個類別,而回應 A 的方法即使混入也會繼續回應 A。

5.15. 靜態方法、屬性和欄位

以下說明應謹慎對待。靜態成員支援仍在進行中,且仍處於實驗階段。以下資訊僅適用於 4.0.12。

可以在特徵中定義靜態方法,但它有許多限制

  • 具有靜態方法的特徵無法靜態編譯或類型檢查。所有靜態方法、屬性和欄位都是動態存取的(這是 JVM 的限制)。

  • 靜態方法不會出現在每個特徵的產生介面中。

  • 特徵被解釋為實作類別的範本,這表示每個實作類別都會取得自己的靜態方法、屬性和欄位。因此,在特徵上宣告的靜態成員不屬於 Trait,而是屬於其實作類別。

  • 您通常不應混用具有相同簽名的靜態和執行個體方法。套用特徵的正常規則適用(包括多重繼承衝突解決)。如果選擇的方法是靜態的,但某些已實作的特徵有執行個體變異,則會發生編譯錯誤。如果選擇的方法是執行個體變異,則靜態變異將被忽略(對於此情況,行為類似於 Java 介面中的靜態方法)。

讓我們從一個簡單的範例開始

trait TestHelper {
    public static boolean CALLED = false        (1)
    static void init() {                        (2)
        CALLED = true                           (3)
    }
}
class Foo implements TestHelper {}
Foo.init()                                      (4)
assert Foo.TestHelper__CALLED                   (5)
1 靜態欄位宣告在特質中
2 靜態方法也宣告在特質中
3 靜態欄位在特質內部更新
4 一個靜態方法init提供給實作類別
5 靜態欄位重新對應以避免菱形問題

通常,不建議使用公開欄位。無論如何,如果你想要這樣做,你必須了解下列程式碼會失敗

Foo.CALLED = true

因為特質本身沒有定義稱為靜態欄位。同樣地,如果你有兩個不同的實作類別,每個都會取得不同的靜態欄位

class Bar implements TestHelper {}              (1)
class Baz implements TestHelper {}              (2)
Bar.init()                                      (3)
assert Bar.TestHelper__CALLED                   (4)
assert !Baz.TestHelper__CALLED                  (5)
1 類別Bar實作特質
2 類別Baz也實作特質
3 init只在Bar上呼叫
4 Bar上的靜態欄位CALLED已更新
5 Baz上的靜態欄位CALLED沒有更新,因為它是不同的

5.16. 繼承狀態的陷阱

我們已經看到特質是有狀態的。特質可以定義欄位或屬性,但當一個類別實作一個特質時,它會依據每個特質取得那些欄位/屬性。因此,考慮下列範例

trait IntCouple {
    int x = 1
    int y = 2
    int sum() { x+y }
}

特質定義兩個屬性,xy,以及一個sum方法。現在讓我們建立一個實作特質的類別

class BaseElem implements IntCouple {
    int f() { sum() }
}
def base = new BaseElem()
assert base.f() == 3

呼叫f的結果是3,因為f委派給特質中的sum,它有狀態。但如果我們改寫成這樣呢?

class Elem implements IntCouple {
    int x = 3                                       (1)
    int y = 4                                       (2)
    int f() { sum() }                               (3)
}
def elem = new Elem()
1 覆寫屬性x
2 覆寫屬性y
3 從特質呼叫sum

如果你呼叫elem.f(),預期的輸出是什麼?實際上是

assert elem.f() == 3

原因是sum方法存取特質的欄位。因此它使用特質中定義的xy值。如果你想使用實作類別的值,那麼你需要使用 getter 和 setter 取消欄位的參照,就像在最後一個範例中

trait IntCouple {
    int x = 1
    int y = 2
    int sum() { getX()+getY() }
}

class Elem implements IntCouple {
    int x = 3
    int y = 4
    int f() { sum() }
}
def elem = new Elem()
assert elem.f() == 7

5.17. 自身型別

5.17.1. 特質的型別約束

有時你會想要寫一個只可以套用在某些型別的特質。例如,你可能想要套用一個特質在一個延伸另一個超出你控制範圍的類別的類別上,而且仍然可以呼叫那些方法。為了說明這一點,讓我們從這個範例開始

class CommunicationService {
    static void sendMessage(String from, String to, String message) {       (1)
        println "$from sent [$message] to $to"
    }
}

class Device { String id }                                                  (2)

trait Communicating {
    void sendMessage(Device to, String message) {
        CommunicationService.sendMessage(id, to.id, message)                (3)
    }
}

class MyDevice extends Device implements Communicating {}                   (4)

def bob = new MyDevice(id:'Bob')
def alice = new MyDevice(id:'Alice')
bob.sendMessage(alice,'secret')                                             (5)
1 一個Service類別,超出你的控制範圍(在一個函式庫中,…​)定義一個sendMessage方法
2 一個 Device 類別,在你的控制之外(在一個函式庫中,…​)
3 定義一個通訊特質,用於可以呼叫服務的裝置
4 定義 MyDevice 作為一個通訊裝置
5 呼叫特質中的方法,並解析 id

在這裡很明顯,Communicating 特質只能套用於 Device。然而,沒有明確的合約來表示這一點,因為特質無法延伸類別。但是,程式碼編譯和執行完全正常,因為特質方法中的 id 將會動態解析。問題在於,沒有任何東西可以防止特質套用於任何 不是 Device 的類別。任何具有 id 的類別都會運作,而任何沒有 id 屬性的類別都會導致執行時期錯誤。

如果你想要啟用類型檢查或對特質套用 @CompileStatic,問題會更加複雜:因為特質不知道自己是一個 Device,所以類型檢查器會抱怨說找不到 id 屬性。

一種可能性是在特質中明確新增一個 getId 方法,但這無法解決所有問題。如果一個方法需要 this 作為一個參數,而且實際上需要它是一個 Device 呢?

class SecurityService {
    static void check(Device d) { if (d.id==null) throw new SecurityException() }
}

如果你想要能夠在特質中呼叫 this,那麼你將需要明確地將 this 轉型為一個 Device。這可能會因為到處都有明確轉型為 this 而變得難以閱讀。

5.17.2. @SelfType 註解

為了使此合約明確,並讓類型檢查器知道 本身的類型,Groovy 提供了一個 @SelfType 註解,它將

  • 讓你宣告實作此特質的類別必須繼承或實作的類型

  • 如果這些類型約束不滿足,則會擲出編譯時期錯誤

因此,在我們之前的範例中,我們可以使用 @groovy.transform.SelfType 註解來修正特質

@SelfType(Device)
@CompileStatic
trait Communicating {
    void sendMessage(Device to, String message) {
        SecurityService.check(this)
        CommunicationService.sendMessage(id, to.id, message)
    }
}

現在,如果您嘗試在不是裝置的類別上實作此特質,將會發生編譯時期錯誤

class MyDevice implements Communicating {} // forgot to extend Device

錯誤將會是

class 'MyDevice' implements trait 'Communicating' but does not extend self type class 'Device'

總之,自我類型是一種宣告特質限制的強大方式,無需直接在特質中宣告合約或無需在各處使用強制轉型,保持適當的關注點分離。

5.17.3. 與 Sealed 標記的差異 (孵化中)

@Sealed@SelfType 都以正交的方式限制使用特質的類別。考慮以下範例

interface HasHeight { double getHeight() }
interface HasArea { double getArea() }

@SelfType([HasHeight, HasArea])                       (1)
@Sealed(permittedSubclasses=[UnitCylinder,UnitCube])  (2)
trait HasVolume {
    double getVolume() { height * area }
}

final class UnitCube implements HasVolume, HasHeight, HasArea {
    // for the purposes of this example: h=1, w=1, l=1
    double height = 1d
    double area = 1d
}

final class UnitCylinder implements HasVolume, HasHeight, HasArea {
    // for the purposes of this example: h=1, diameter=1
    // radius=diameter/2, area=PI * r^2
    double height = 1d
    double area = Math.PI * 0.5d**2
}

assert new UnitCube().volume == 1d
assert new UnitCylinder().volume == 0.7853981633974483d
1 HasVolume 特質的所有用法都必須實作或延伸 HasHeightHasArea
2 只有 UnitCubeUnitCylinder 可以使用特質

對於單一類別實作特質的簡化案例,例如

final class Foo implements FooTrait {}

然後,

@SelfType(Foo)
trait FooTrait {}

@Sealed(permittedSubclasses='Foo') (1)
trait FooTrait {}
1 或如果 FooFooTrait 在同一個來源檔案中,則僅使用 @Sealed

可以表達此限制。一般而言,較偏好前者。

5.18. 限制

5.18.1. 與 AST 轉換的相容性

特質與 AST 轉換正式不相容。其中一些,例如 @CompileStatic,將套用於特質本身 (而非實作類別),而其他則將套用於實作類別和特質。絕對無法保證 AST 轉換在特質上執行的結果會與在一般類別上執行的結果相同,因此請自行承擔風險使用!

5.18.2. 前置和後置運算

在特質中,如果前置和後置運算會更新特質的欄位,則不允許使用

trait Counting {
    int x
    void inc() {
        x++                             (1)
    }
    void dec() {
        --x                             (2)
    }
}
class Counter implements Counting {}
def c = new Counter()
c.inc()
1 x 定義在特質中,不允許使用後置遞增
2 x 定義在特質中,不允許使用前置遞減

解決方法是改用 += 運算子。

6. 記錄類別 (孵化中)

記錄類別,簡稱記錄,是一種特殊類別,可用於建模純粹資料聚合。它們提供簡潔的語法,比一般類別更簡便。Groovy 已經有 AST 轉換,例如 @Immutable@Canonical,它們已經大幅減少了繁瑣的步驟,但記錄已在 Java 中引入,而 Groovy 中的記錄類別設計為與 Java 記錄類別保持一致。

例如,假設我們要建立一個表示電子郵件訊息的 Message 記錄。為了這個範例的目的,我們簡化訊息,只包含一個 寄件者 電子郵件地址、一個 收件者 電子郵件地址和一個訊息 內文。我們可以定義此記錄如下

record Message(String from, String to, String body) { }

我們會像一般類別一樣使用記錄類別,如下所示

def msg = new Message('me@myhost.com', 'you@yourhost.net', 'Hello!')
assert msg.toString() == 'Message[from=me@myhost.com, to=you@yourhost.net, body=Hello!]'

精簡的儀式讓我們免於定義明確的欄位、取得器和 toStringequalshashCode 方法。事實上,這是下列粗略等效項的簡寫

final class Message extends Record {
    private final String from
    private final String to
    private final String body
    private static final long serialVersionUID = 0

    /* constructor(s) */

    final String toString() { /*...*/ }

    final boolean equals(Object other) { /*...*/ }

    final int hashCode() { /*...*/ }

    String from() { from }
    // other getters ...
}

請注意記錄取得器的特殊命名慣例。它們與欄位同名(而不是 JavaBean 慣例中常見的大寫加上「get」前綴)。與其參考記錄的欄位或屬性,記錄通常使用術語 組成部分。因此我們的 Message 記錄有 fromtobody 組成部分。

就像在 Java 中,你可以透過撰寫自己的方法來覆寫通常隱含提供的那些方法

record Point3D(int x, int y, int z) {
    String toString() {
        "Point3D[coords=$x,$y,$z]"
    }
}

assert new Point3D(10, 20, 30).toString() == 'Point3D[coords=10,20,30]'

你也可以在記錄中使用泛型,就像一般的方式一樣。例如,考慮下列 Coord 記錄定義

record Coord<T extends Number>(T v1, T v2){
    double distFromOrigin() { Math.sqrt(v1()**2 + v2()**2 as double) }
}

它可以用於下列方式

def r1 = new Coord<Integer>(3, 4)
assert r1.distFromOrigin() == 5
def r2 = new Coord<Double>(6d, 2.5d)
assert r2.distFromOrigin() == 6.5d

6.1. 特殊記錄功能

6.1.1. 精簡建構函式

記錄有一個隱含建構函式。這可以用一般的方式覆寫,方法是提供你自己的建構函式 - 如果你這麼做,你需要確定設定所有欄位。然而,為了簡潔,可以使用精簡建構函式語法,其中省略一般建構函式參數宣告的部分。對於這個特殊情況,仍然提供一般隱含建構函式,但會擴充精簡建構函式定義中提供的陳述式

public record Warning(String message) {
    public Warning {
        Objects.requireNonNull(message)
        message = message.toUpperCase()
    }
}

def w = new Warning('Help')
assert w.message() == 'HELP'

6.1.2. 可序列化

Groovy 原生 記錄遵循 特殊慣例,適用於 Java 記錄的可序列化性。Groovy 類記錄 類別(如下所述)遵循一般的 Java 類別可序列化慣例。

6.2. Groovy 的加強功能

6.2.1. 參數預設值

Groovy 支援建構函式參數的預設值。此功能也適用於記錄,如下所示的記錄定義,其中有 `y` 和 `color` 的預設值

record ColoredPoint(int x, int y = 0, String color = 'white') {}

當省略參數(從右邊刪除一個或多個參數)時,會以其預設值取代,如下例所示

assert new ColoredPoint(5, 5, 'black').toString() == 'ColoredPoint[x=5, y=5, color=black]'
assert new ColoredPoint(5, 5).toString() == 'ColoredPoint[x=5, y=5, color=white]'
assert new ColoredPoint(5).toString() == 'ColoredPoint[x=5, y=0, color=white]'

此處理遵循 Groovy 建構函式預設參數的慣例,基本上會自動提供建構函式下列簽章

ColoredPoint(int, int, String)
ColoredPoint(int, int)
ColoredPoint(int)

也可以使用命名參數(預設值也適用於此處)

assert new ColoredPoint(x: 5).toString() == 'ColoredPoint[x=5, y=0, color=white]'
assert new ColoredPoint(x: 0, y: 5).toString() == 'ColoredPoint[x=0, y=5, color=white]'

您可以停用預設參數處理,如下所示

@TupleConstructor(defaultsMode=DefaultsMode.OFF)
record ColoredPoint2(int x, int y, String color) {}
assert new ColoredPoint2(4, 5, 'red').toString() == 'ColoredPoint2[x=4, y=5, color=red]'

這會產生單一建構函式,與 Java 的預設相同。如果在此情況下省略參數,則會產生錯誤。

您可以強制所有屬性都有預設值,如下所示

@TupleConstructor(defaultsMode=DefaultsMode.ON)
record ColoredPoint3(int x, int y = 0, String color = 'white') {}
assert new ColoredPoint3(y: 5).toString() == 'ColoredPoint3[x=0, y=5, color=white]'

任何沒有明確初始值的屬性/欄位,都將給予參數類型(null,或基本類型的零/false)的預設值。

深入探討

我們先前描述了一個 `Message` 記錄,並顯示其粗略的等效項。事實上,Groovy 會經歷一個中間階段,其中 `record` 關鍵字會被 `class` 關鍵字取代,並附帶一個 `@RecordType` 注解

@RecordType
class Message {
    String from
    String to
    String body
}

然後,`@RecordType` 本身會被處理為一個元注解(注解收集器),並擴充為其組成子注解,例如 `@TupleConstructor`、`@POJO`、`@RecordBase` 等。這在某種意義上是一個實作細節,通常可以忽略。但是,如果您想要自訂或設定記錄實作,您可能希望回到 `@RecordType` 樣式,或使用其中一個組成子注解來擴充您的記錄類別。

6.2.2. 宣告式 `toString` 自訂

根據 Java,您可以透過撰寫自己的程式碼來自訂記錄的 `toString` 方法。如果您偏好更宣告式的樣式,您也可以使用 Groovy 的 `@ToString` 轉換來覆寫預設記錄的 `toString`。舉例來說,您可以將三維點記錄如下

package threed

import groovy.transform.ToString

@ToString(ignoreNulls=true, cache=true, includeNames=true,
          leftDelimiter='[', rightDelimiter=']', nameValueSeparator='=')
record Point(Integer x, Integer y, Integer z=null) { }

assert new Point(10, 20).toString() == 'threed.Point[x=10, y=20]'

我們透過包含套件名稱(預設情況下記錄會排除)和快取 `toString` 值(因為它不會針對這個不可變記錄而變更)來自訂 `toString`。我們也忽略 null 值(我們定義中 `z` 的預設值)。

我們可以對二維點有類似的定義

package twod

import groovy.transform.ToString

@ToString(ignoreNulls=true, cache=true, includeNames=true,
          leftDelimiter='[', rightDelimiter=']', nameValueSeparator='=')
record Point(Integer x, Integer y) { }

assert new Point(10, 20).toString() == 'twod.Point[x=10, y=20]'

我們在此可以看到,如果沒有套件名稱,它將與我們之前的範例有相同的 toString。

6.2.3. 取得記錄元件值的清單

我們可以像這樣從記錄中取得元件值作為清單

record Point(int x, int y, String color) { }

def p = new Point(100, 200, 'green')
def (x, y, c) = p.toList()
assert x == 100
assert y == 200
assert c == 'green'

您可以使用 @RecordOptions(toList=false) 來停用此功能。

6.2.4. 取得記錄元件值的映射

我們可以像這樣從記錄中取得元件值作為映射

record Point(int x, int y, String color) { }

def p = new Point(100, 200, 'green')
assert p.toMap() == [x: 100, y: 200, color: 'green']

您可以使用 @RecordOptions(toMap=false) 來停用此功能。

6.2.5. 取得記錄中元件的數量

我們可以像這樣取得記錄中元件的數量

record Point(int x, int y, String color) { }

def p = new Point(100, 200, 'green')
assert p.size() == 3

您可以使用 @RecordOptions(size=false) 來停用此功能。

6.2.6. 取得記錄中第 n 個元件

我們可以使用 Groovy 的一般位置索引來取得記錄中的特定元件,如下所示

record Point(int x, int y, String color) { }

def p = new Point(100, 200, 'green')
assert p[1] == 200

您可以使用 @RecordOptions(getAt=false) 來停用此功能。

6.3. 選擇性 Groovy 功能

6.3.1. 複製

製作記錄的副本並變更一些元件會很有用。這可以使用選用性的 copyWith 方法來完成,此方法接受命名參數。記錄元件會從提供的參數中設定。對於未提及的元件,會使用原始記錄元件的(淺層)副本。以下是您如何對 Fruit 記錄使用 copyWith 的方式

@RecordOptions(copyWith=true)
record Fruit(String name, double price) {}
def apple = new Fruit('Apple', 11.6)
assert 'Apple' == apple.name()
assert 11.6 == apple.price()

def orange = apple.copyWith(name: 'Orange')
assert orange.toString() == 'Fruit[name=Orange, price=11.6]'

copyWith 功能可以透過將 RecordOptions#copyWith 註解屬性設定為 false 來停用。

6.3.2. 深層不變性

與 Java 一樣,記錄預設提供淺層不變性。Groovy 的 @Immutable 轉換對各種可變資料類型執行防禦性複製。記錄可以使用此防禦性複製來獲得深層不變性,如下所示

@ImmutableProperties
record Shopping(List items) {}

def items = ['bread', 'milk']
def shop = new Shopping(items)
items << 'chocolate'
assert shop.items() == ['bread', 'milk']

這些範例說明了 Groovy 的記錄功能背後的原則,提供三種便利性等級

  • 使用 record 關鍵字以獲得最大的簡潔性

  • 使用宣告式註解支援低儀式自訂化

  • 當需要完全控制時,允許使用一般方法實作

6.3.3. 取得記錄的元件為類型化元組

您可以取得記錄的元件為類型化元組

import groovy.transform.*

@RecordOptions(components=true)
record Point(int x, int y, String color) { }

@CompileStatic
def method() {
    def p1 = new Point(100, 200, 'green')
    def (int x1, int y1, String c1) = p1.components()
    assert x1 == 100
    assert y1 == 200
    assert c1 == 'green'

    def p2 = new Point(10, 20, 'blue')
    def (x2, y2, c2) = p2.components()
    assert x2 * 10 == 100
    assert y2 ** 2 == 400
    assert c2.toUpperCase() == 'BLUE'

    def p3 = new Point(1, 2, 'red')
    assert p3.components() instanceof Tuple3
}

method()

Groovy 只有有限數量的 TupleN 類別。如果您的記錄中有大量的元件,您可能無法使用此功能。

6.4. 與 Java 的其他差異

Groovy 支援建立類似記錄的類別以及原生記錄。類似記錄的類別不會延伸 Java 的 Record 類別,且此類別不會被 Java 視為記錄,但會具有類似的屬性。

@RecordOptions 註解(@RecordType 的一部分)支援 mode 註解屬性,它可以採用三個值之一(AUTO 為預設值)

NATIVE

產生類似 Java 會執行的類別。在 JDK16 之前的 JDK 上編譯時會產生錯誤。

EMULATE

為所有 JDK 版本產生類似記錄的類別。

AUTO

為 JDK16+ 產生原生記錄,否則模擬記錄。

您使用 record 關鍵字或 @RecordType 註解與模式無關。

7. 密封層級(孵化中)

密封類別、介面和特質限制哪些子類別可以延伸/實作它們。在密封類別之前,類別層級設計者有兩個主要選項

  • 將類別設為 final 以不允許延伸。

  • 將類別設為 public 和 non-final 以允許任何人延伸。

與這些全有或全無的選擇相比,密封類別提供了一個折衷方案。

密封類別也比以前用於嘗試達成折衷方案的其他技巧更靈活。例如,對於類別層級,受保護和封裝私有的存取修飾詞提供了一些限制繼承層級的能力,但通常是以犧牲這些層級的靈活使用為代價。

密封層級在已知的類別、介面和特質層級中提供完整的繼承,但在層級之外禁用或僅提供受控的繼承。

舉例來說,假設我們要建立一個只包含圓形和正方形的形狀層級。我們也希望形狀介面能夠參考我們層級中的實例。我們可以建立如下層級

sealed interface ShapeI permits Circle,Square { }
final class Circle implements ShapeI { }
final class Square implements ShapeI { }

Groovy 也支援替代的註解語法。我們認為關鍵字樣式較佳,但如果您使用的編輯器尚未支援 Groovy 4,您可以選擇註解樣式。

@Sealed(permittedSubclasses=[Circle,Square]) interface ShapeI { }
final class Circle implements ShapeI { }
final class Square implements ShapeI { }

我們可以有一個類型為 ShapeI 的參考,由於 permits 子句,它可以指向 CircleSquare,而且由於我們的類別是 final,我們知道未來不會在我們的階層中新增其他類別。至少在不變更 permits 子句和重新編譯的情況下不會。

一般來說,我們可能希望立即鎖定類別階層的某些部分,就像我們在此處將子類別標記為 final 的情況,但其他時候我們可能希望允許進一步的受控繼承。

sealed class Shape permits Circle,Polygon,Rectangle { }

final class Circle extends Shape { }

class Polygon extends Shape { }
non-sealed class RegularPolygon extends Polygon { }
final class Hexagon extends Polygon { }

sealed class Rectangle extends Shape permits Square{ }
final class Square extends Rectangle { }
<按一下以查看替代註解語法>
@Sealed(permittedSubclasses=[Circle,Polygon,Rectangle]) class Shape { }

final class Circle extends Shape { }

class Polygon extends Shape { }
@NonSealed class RegularPolygon extends Polygon { }
final class Hexagon extends Polygon { }

@Sealed(permittedSubclasses=Square) class Rectangle extends Shape { }
final class Square extends Rectangle { }

 
在此範例中,我們 Shape 允許的子類別為 CirclePolygonRectangleCirclefinal,因此階層的該部分無法延伸。Polygon 隱含為非密封,而 RegularPolygon 明確標記為 non-sealed。這表示我們的階層開放給任何進一步的延伸,透過子類別化,如 Polygon → RegularPolygonRegularPolygon → Hexagon 所示。Rectangle 本身是密封的,這表示階層的該部分可以延伸,但只能以受控的方式(僅允許 Square)。

密封類別對於建立需要包含實例特定資料的類似列舉的相關類別很有用。例如,我們可能有下列列舉

enum Weather { Rainy, Cloudy, Sunny }
def forecast = [Weather.Rainy, Weather.Sunny, Weather.Cloudy]
assert forecast.toString() == '[Rainy, Sunny, Cloudy]'

但我們現在希望在天氣預報中也新增天氣特定實例資料。我們可以如下變更我們的抽象

sealed abstract class Weather { }
@Immutable(includeNames=true) class Rainy extends Weather { Integer expectedRainfall }
@Immutable(includeNames=true) class Sunny extends Weather { Integer expectedTemp }
@Immutable(includeNames=true) class Cloudy extends Weather { Integer expectedUV }
def forecast = [new Rainy(12), new Sunny(35), new Cloudy(6)]
assert forecast.toString() == '[Rainy(expectedRainfall:12), Sunny(expectedTemp:35), Cloudy(expectedUV:6)]'

密封階層在指定代數或抽象資料類型 (ADT) 時也很有用,如下列範例所示

import groovy.transform.*

sealed interface Tree<T> {}
@Singleton final class Empty implements Tree {
    String toString() { 'Empty' }
}
@Canonical final class Node<T> implements Tree<T> {
    T value
    Tree<T> left, right
}

Tree<Integer> tree = new Node<>(42, new Node<>(0, Empty.instance, Empty.instance), Empty.instance)
assert tree.toString() == 'Node(42, Node(0, Empty, Empty), Empty)'

密封階層與記錄搭配使用時效果很好,如下列範例所示

sealed interface Expr {}
record ConstExpr(int i) implements Expr {}
record PlusExpr(Expr e1, Expr e2) implements Expr {}
record MinusExpr(Expr e1, Expr e2) implements Expr {}
record NegExpr(Expr e) implements Expr {}

def threePlusNegOne = new PlusExpr(new ConstExpr(3), new NegExpr(new ConstExpr(1)))
assert threePlusNegOne.toString() == 'PlusExpr[e1=ConstExpr[i=3], e2=NegExpr[e=ConstExpr[i=1]]]'

7.1. 與 Java 的差異

  • Java 沒有提供密封類別子類別的預設修改子,並要求指定 finalsealednon-sealed 之一。Groovy 預設為 non-sealed,但您仍然可以使用 non-sealed/@NonSealed(如果您希望的話)。我們預期程式碼檢查工具 CodeNarc 最終會有一個規則,用於尋找 non-sealed 的存在,因此想要更嚴格的樣式的開發人員將能夠使用 CodeNarc 和該規則(如果他們想要的話)。

  • 目前,Groovy 沒有檢查 permittedSubclasses 中提到的所有類別在編譯時是否可用,以及是否與基礎密封類別一起編譯。這可能會在 Groovy 的未來版本中變更。

Groovy 支援將類別註解為密封類別以及「原生」密封類別。

@SealedOptions 註解支援 mode 註解屬性,它可以採用三個值之一(預設為 AUTO

NATIVE

產生類似於 Java 所會做的類別。在 JDK17 之前的 JDK 上編譯時會產生錯誤。

EMULATE

表示類別使用 @Sealed 註解進行封裝。此機制適用於 JDK8+ 的 Groovy 編譯器,但 Java 編譯器不識別它。

AUTO

為 JDK17+ 產生原生記錄,否則模擬記錄。

您使用 sealed 關鍵字或 @Sealed 註解與模式無關。