本章涵盖 Groovy 编程语言的面向对象方面。
1. 类型
1.1. 基本类型
Groovy 支持与 Java 语言规范 中定义的相同的基本类型
-
整数类型:
byte
(8 位)、short
(16 位)、int
(32 位)和long
(64 位) -
浮点数类型:
float
(32 位)和double
(64 位) -
boolean
类型(true
或false
之一) -
char
类型(16 位,可用作数字类型,表示 UTF-16 代码)
与 Java 一样,Groovy 在需要与任何基本类型相对应的对象时使用相应的包装类
基本类型 | 包装类 |
---|---|
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 | 在运行时查看该字段显示它已被自动包装 |
1.2. 引用类型
除了基本类型之外,其他所有东西都是对象,并具有与其相关的类来定义其类型。我们将在后面讨论类以及与类相关或类似类的概念,例如接口、特性和记录。
我们可以声明两个变量,类型分别为 String 和 List,如下所示
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(即使可以省略 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
类只是为了为 Outer2
类提供 run
方法的实现。匿名内部类可以帮助消除这种情况下的冗长代码。该主题将在后面介绍。
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 | 接口只定义方法签名 |
接口的方法总是公共的。在接口中使用protected
或private
方法是错误的
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注解将构造函数添加到类中。
通常,一旦声明了至少一个构造函数,该类只能通过调用它的一个构造函数来实例化。值得注意的是,在这种情况下,你通常无法使用命名参数创建类。Groovy 支持命名参数,只要类包含一个无参数构造函数或提供一个以Map
参数作为第一个(可能也是唯一)参数的构造函数——有关详细信息,请参见下一节。
使用已声明的构造函数有三种形式。第一个是普通的 Java 方式,使用new
关键字。其他的依赖于将列表强制转换为所需类型。在这种情况下,可以使用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 | 实例化时给出name 和age 参数 |
但是,重要的是要注意,这种方法赋予了构造函数调用者更多的权力,同时也增加了调用者对名称和值类型的正确性的责任。因此,如果需要更多控制,建议使用位置参数声明构造函数。
说明
-
虽然上面的例子没有提供构造函数,但你也可以提供一个无参数构造函数,或者第一个参数是
Map
的构造函数,通常它是唯一的参数。 -
当没有(或使用无参数)声明构造函数时,Groovy 会用调用无参数构造函数,然后调用每个提供的命名属性的 setter 来替换命名构造函数调用。
-
当第一个参数是 Map 时,Groovy 会将所有命名参数组合成一个 Map(无论顺序如何),并将该映射作为第一个参数提供。如果你的属性被声明为
final
(因为它们将在构造函数中设置,而不是在构造函数之后使用 setter 设置),那么这可能是一个很好的方法。 -
你可以通过提供位置构造函数以及无参数或 Map 构造函数来支持命名和位置构造。
-
你可以通过有一个构造函数来支持混合构造,其中第一个参数是 Map,但还有其他位置参数。谨慎使用这种风格。
3.2. 方法
Groovy 方法与其他语言非常相似。下一节将介绍一些特殊之处。
3.2.1. 方法定义
方法用返回类型或def
关键字定义,使返回类型无类型。方法还可以接收任意数量的参数,这些参数可能没有显式声明它们的类型。Java 修饰符可以正常使用,如果没有提供可见性修饰符,则方法为公共的。
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.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' }
也许正如预期的那样,使用 String
和 Integer
参数调用 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)
对于前两个调用也有效,但与类型完全匹配的变体相比,它不是最接近的匹配。为了确定最接近的匹配,运行时有一个关于实际参数类型与声明的参数类型之间“距离”的概念,并试图最小化所有参数的总距离。
下表说明了一些影响距离计算的因素。
方面 | 示例 |
---|---|
直接实现的接口比继承层次结构中更上层的接口更匹配。 |
给定这些接口和方法定义
直接实现的接口将匹配
|
对象数组优于对象。 |
|
非可变参数变体优于可变参数变体。 |
|
如果两个可变参数变体适用,则使用可变参数数量最少的那个变体。 |
|
接口优于超类。 |
|
对于基本类型参数,声明的参数类型相同或略大更好。 |
|
在两个变体的距离完全相同时,这被认为是模棱两可的,并将导致运行时异常
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')
可以使用强制类型转换来选择所需的方法
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 源文件中定义的字段具有
-
一个强制的访问修饰符(
public
、protected
或private
) -
一个或多个可选的修饰符(
static
、final
、synchronized
) -
一个可选的类型
-
一个强制的名称
class Data {
private int id (1)
protected String description (2)
public static final boolean DEBUG = false (3)
}
1 | 一个名为 id 的 private 字段,类型为 int |
2 | 一个名为 description 的 protected 字段,类型为 String |
3 | 一个名为 DEBUG 的 public 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 遵循这些相同的约定,但提供了一种更简单的定义属性的方法。您可以使用以下方法定义属性:
-
不存在访问修饰符(没有
public
、protected
或private
) -
一个或多个可选的修饰符(
static
、final
、synchronized
) -
一个可选的类型
-
一个强制的名称
然后,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 方法,该方法对字段执行直接读取访问 |
值得注意的是,这种直接访问后备字段的行为是为了防止在使用定义属性的类中的属性访问语法时发生堆栈溢出。
可以使用实例的元 properties
字段来列出类的属性
class Person {
String name
int age
}
def p = new Person()
assert p.properties.keySet().containsAll(['name','age'])
按照惯例,即使没有提供后备字段,Groovy 也将识别属性,只要有遵循 JavaBeans 规范的 getter 或 setter。例如
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 的名称将通过将属性名大写并添加 get
或 set
前缀(或可选地对布尔值 getter 使用“is”)来形成。因此,getLength
将是 length
属性的 getter,setFirstName
将是 firstName
属性的 setter。isEmpty
可能是名为 empty
的属性的 getter 方法名。
以大写字母开头的属性名将只有前缀添加的 getter/setter。因此,即使 |
JavaBeans 规范对通常可能是首字母缩略词的属性做出了特殊情况。如果属性名的前两个字母是大写,则不执行大写(或者更重要的是,如果从访问器方法名生成属性名,则不执行小写)。因此,getURL
将是 URL
属性的 getter。
由于 JavaBeans 规范中特殊的“首字母缩略词处理”属性命名逻辑,属性名称的转换是非对称的。这会导致一些奇怪的边缘情况。Groovy 采用了一种命名约定,避免了一种可能看起来有点奇怪但当时很流行的歧义,并且由于历史原因一直保留了下来(迄今为止)。Groovy 会查看属性名称的第二个字母。如果该字母是大写,则该属性被认为是首字母缩略词样式的属性,并且不进行大写,否则进行正常大写。虽然我们*绝不*推荐这样做,但它确实允许你拥有看似“重复命名”的属性,例如你可以拥有 |
属性的修饰符
我们已经看到属性是通过省略可见性修饰符来定义的。一般来说,任何其他修饰符,例如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 | 声明具有 setter/getter 注解的 name 属性 |
显式访问器方法
如果类中显式定义了 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 变换来说尤其如此,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 只能应用于TYPE 或METHOD |
可能的 target 列表可以在java.lang.annotation.ElementType中找到。
Groovy 不支持在 Java 8 中引入的java.lang.annotation.ElementType#TYPE_PARAMETER和java.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 | 如果需要同时设置value 和statusCode ,则需要为默认的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 | 将闭包的delegate 设置为我们的环境变量 |
8 | 调用闭包,它就是注解闭包。它将返回一个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 支持 **预编译** 和 **源代码形式** 的元注解。这意味着您的元注解 **可能** 被预编译,或者您可以将它与您当前正在编译的源代码放在同一个源代码树中。
INFO: 元注解是 Groovy 独有的特性。您无法用元注解来注解 Java 类,并期望它与 Groovy 中的行为相同。同样地,您也不能用 Java 编写元注解:元注解的定义 **和** 使用都必须是 Groovy 代码。但您可以将 Java 注解和 Groovy 注解收集到您的元注解中。
当 Groovy 编译器遇到一个用元注解注解的类时,它会 **用收集到的注解替换** 它。因此,在我们之前的示例中,它将用 @Transactional
和 @Service
替换 @TransactionalService
。
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 注解定义了类型为 String 的 value 成员 |
2 | @Bar 注解也定义了类型为 String 的 value 成员 |
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
参数,它可以用来改变默认处理器在遇到重复注解时如何处理注解替换。
INFO: 自定义处理器(接下来讨论)可能支持也可能不支持此参数。
例如,假设您创建一个包含 @ToString
注解的元注解,然后将您的元注解放在一个已经具有显式 @ToString
注解的类上。这应该是一个错误吗?应该应用两个注解吗?一个优先于另一个吗?没有正确答案。在某些情况下,这些答案中的任何一个都是正确的。因此,Groovy 不会尝试预先确定一种处理重复注解问题的正确方法,而是让您编写自己的自定义元注解处理器(接下来会介绍),并让您在 AST 转换中编写您喜欢的任何检查逻辑——AST 转换是聚合的常见目标。也就是说,只需设置 mode
,就可以在任何额外代码中自动处理许多常见的预期场景。mode
参数的行为由选择的 AnnotationCollectorMode
枚举值决定,总结如下表。
Mode |
描述 |
DUPLICATE |
将始终插入注解集合中的注解。在所有转换运行后,如果存在多个注解(不包括具有 SOURCE 保留的注解),将是一个错误。 |
PREFER_COLLECTOR |
将添加来自收集器的注解,并删除任何具有相同名称的现有注解。 |
PREFER_COLLECTOR_MERGED |
将添加来自收集器的注解,并删除任何具有相同名称的现有注解,但将任何在现有注解中找到的新参数合并到添加的注解中。 |
PREFER_EXPLICIT |
如果发现任何具有相同名称的现有注解,将忽略来自收集器的注解。 |
PREFER_EXPLICIT_MERGED |
如果发现任何具有相同名称的现有注解,将忽略来自收集器的注解,但将收集器注解上的任何新参数添加到现有注解中。 |
4.2.5. 自定义元注解处理器
自定义注解处理器可以让您选择如何将元注解扩展为收集到的注解。在这种情况下,元注解的行为完全由您决定。要做到这一点,您必须
-
创建一个元注解处理器,扩展 org.codehaus.groovy.transform.AnnotationCollectorTransform
-
声明将在元注解声明中使用的处理器
为了说明这一点,我们将探讨元注解 @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
参数,该参数引用一个将 **生成** 注解的类。
以下是自定义处理器的实现方式
@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 |
特质只支持 public 和 private 方法。不支持 protected 或 package private 范围。 |
5.1.4. 最终方法
如果我们有一个实现特质的类,从概念上讲,来自特质方法的实现被 "继承" 到类中。但是,实际上,没有包含这些实现的基类。相反,它们被直接编织到类中。方法上的 final 修饰符只是指示编织方法的修饰符是什么。虽然将继承和覆盖或多重继承具有相同签名但最终和非最终变体混合的方法视为不良风格可能是合理的,但 Groovy 并不禁止这种情况。应用普通方法选择,使用的修饰符将根据结果方法确定。如果您想要不能被覆盖的特质实现方法,可以考虑创建一个实现所需特质的基类。
5.2. this 的含义
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 类同时实现了FlyingAbility 和SpeakingAbility 。 |
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 属性从实现Polite 的Person 类可见。 |
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 是一个继承了WithId 和WithName 的特性。 |
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方法,如methodMissing
或propertyMissing
,在这种情况下,实现类将继承来自特性的行为,如下例所示。
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
子句中**最后声明的特性**的方法获胜。这里,B
是在A
之后声明的,因此来自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 包装成一个同时实现A 和B 的东西。 |
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 特性。 |
这有效,但这种方法有一些缺点。
-
日志记录逻辑绑定到“具体”处理程序。
-
我们在
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
的调用,则
-
如果类实现另一个特征,则该调用将委托给链中的下一个特征
-
如果没有链中剩余的特征,则
super
引用实现类的超类(this)
例如,由于这种行为,可以装饰最终类
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 是一个抽象方法 |
由于getName
是Greeter
特征中的唯一抽象方法,所以你可以写
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() { /* ... */ }
}
在这个例子中,我们创建了一个简单的测试用例,它使用两个属性(config 和shell),并在多个测试方法中使用它们。现在想象一下,你想测试相同的内容,但使用另一个不同的编译器配置。一种选择是创建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. 与 mixins 的区别
与 Groovy 中可用的 mixins 有几个概念上的区别。注意,我们指的是运行时 mixins,而不是@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 | 将 B 混合到 A 中 |
4 | 我们可以调用methodFromA |
5 | 我们也可以调用methodFromB |
6 | 该对象是A 的实例 |
7 | 但它不是B 的实例 |
最后一点实际上非常重要,说明了 mixins 比特征有优势的地方:实例不会被修改,所以如果你将某个类混合到另一个类中,就不会生成第三个类,响应 A 的方法将继续响应 A,即使混合了 A。
5.15. 静态方法、属性和字段
以下说明需要注意。静态成员支持正在开发中,目前仍在实验阶段。以下信息仅适用于 4.0.22。 |
可以在特征中定义静态方法,但它有许多限制
-
包含静态方法的特征不能进行静态编译或类型检查。所有静态方法、属性和字段都是动态访问的(这是 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
因为特征本身没有名为CALLED的静态字段。同样,如果你有两个不同的实现类,每个类都会获得一个不同的静态字段
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 }
}
该特征定义了两个属性,x
和y
,以及一个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
方法访问特征的字段。因此它使用特征中定义的x
和y
值。如果你想使用来自实现类的值,那么你需要使用 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 特性所有用法都必须实现或扩展 HasHeight 和 HasArea |
2 | 只有 UnitCube 或 UnitCylinder 可以使用该特性 |
对于单个类实现特性的退化情况,例如
final class Foo implements FooTrait {}
那么,
@SelfType(Foo)
trait FooTrait {}
或者
@Sealed(permittedSubclasses='Foo') (1)
trait FooTrait {}
1 | 或者,如果 Foo 和 FooTrait 位于同一个源文件中,则只需 @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 已经有了像 @Immutable
和 @Canonical
这样的 AST 变换,它们已经极大地减少了仪式,但记录已在 Java 中引入,Groovy 中的记录类旨在与 Java 记录类保持一致。
例如,假设我们想创建一个 Message
记录来表示电子邮件消息。为了便于说明,让我们将此类消息简化为仅包含发件人电子邮件地址、收件人电子邮件地址和消息正文。我们可以按如下方式定义这样的记录
record Message(String from, String to, String body) { }
我们使用记录类的方式与使用普通类相同,如下所示
def msg = new Message('[email protected]', '[email protected]', 'Hello!')
assert msg.toString() == 'Message[[email protected], [email protected], body=Hello!]'
减少的仪式使我们无需定义显式字段、getter 和 toString
、equals
和 hashCode
方法。实际上,它相当于以下粗略的等效代码
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 ...
}
请注意记录 getter 的特殊命名约定。它们与字段同名(而不是常见的 JavaBean 约定,即大写字母加“get”前缀)。记录的字段或属性通常被称为组件。因此,我们的 Message
记录具有 from
、to
和 body
组件。
就像在 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)。
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
值来进行自定义,因为该值不会改变,因为该记录是不可变的。我们还忽略了空值(我们定义中 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]'
可以通过将 RecordOptions#copyWith
注释属性设置为 false
来禁用 copyWith
功能。
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 且非 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
子句,它可以指向 Circle
或 Square
,并且由于我们的类是 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
子类是 Circle
、Polygon
和 Rectangle
。Circle
是 final
,因此该部分层次结构无法扩展。Polygon
隐式为非密封,RegularPolygon
显式标记为 non-sealed
。这意味着我们的层次结构对通过子类化进行任何进一步扩展都是开放的,如 Polygon → RegularPolygon
和 RegularPolygon → 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 为密封类的子类不提供默认修饰符,并要求指定
final
、sealed
或non-sealed
之一。Groovy 默认情况下为非密封,但您仍然可以使用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
注释,这都与模式无关。